File uploads look simple until one request refuses to parse. The browser sends the file, the route handler receives it, and then request.formData() throws something like this:
TypeError: Failed to parse body as FormData.
Nothing in the message points at the real cause. In most cases I’ve seen, the culprit is a single line in the client code: a hand-written Content-Type header that quietly destroys the multipart boundary.
In this post I’ll explain what the boundary does, show the bug and the fix, cover the Pages Router and server-to-server cases, and then walk through how to test your upload endpoint in Burp Suite, including the content-type findings Burp can actually raise against a Next.js API.
What does the multipart boundary do?
A multipart/form-data body is a series of parts separated by a delimiter. The Content-Type header tells the server what that delimiter is, using the boundary parameter. A valid request looks like this:
POST /api/upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf
<binary data>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Three details matter:
- The delimiter lines in the body are two hyphens plus the boundary value. The header value does not start with
--, even though browsers happen to generate values that begin with dashes. - The closing delimiter ends with two extra hyphens. A body without it is truncated, and parsers reject it.
- The boundary in the header must match the body exactly. If the server can’t find the boundary, or can’t match it against the body, it has nothing to split on.
Per the MDN documentation for Request.formData(), the method throws a TypeError when it can’t determine the MIME type from the headers or when the body can’t be parsed as form data. Both cases produce the error above.
Pro Tip: The boundary is generated by whoever builds the body: the browser,
curl -F, or a library. Your code should almost never type it by hand.
What is the most common cause?
Setting Content-Type manually when sending a FormData object with fetch. I see this one in code reviews constantly, usually added out of habit from JSON requests:
// ❌ Breaks the boundary
await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "multipart/form-data" },
body: formData,
});
When you pass a FormData body and don’t set the header, fetch generates the full value, boundary included. When you set it yourself, you override that value with one that has no boundary. The body is still encoded with the original one, so the server receives a header that points nowhere:
Content-Type: multipart/form-data
The fix is to delete the header:
// ✅ Let fetch generate Content-Type with the boundary
const formData = new FormData();
formData.append("file", fileInput.files[0]);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
This is a documented trap in the Next.js community for years, and it applies to any fetch-based client, not only Next.js.
How should I write the App Router route handler?
In Next.js 14, route handlers receive a standard Request, so you can call request.formData() directly. A sturdier version handles failures and validates the file:
// app/api/upload/route.ts
import { NextResponse } from "next/server";
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
export async function POST(request: Request) {
let formData: FormData;
try {
formData = await request.formData();
} catch {
// Malformed multipart body or wrong Content-Type
return NextResponse.json(
{ error: "Invalid multipart/form-data body" },
{ status: 400 }
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
return NextResponse.json({
name: file.name,
size: file.size,
});
}
Notice two choices:
- A malformed upload returns
400, not500. A bad client request is not a server fault, and a clean400keeps your logs and scanner reports meaningful. - File size is checked on the server. Client-side limits are a convenience, not a control.
Pro Tip:
request.formData()reads the whole body before returning. For large files, that means the entire upload lives in memory. If you expect big uploads, consider sending files directly to object storage with a presigned URL and keeping only the metadata in your API.
What changes in the Pages Router?
In pages/api, Next.js’s built-in body parser handles JSON and URL-encoded bodies but doesn’t parse multipart. You turn the body parser off and use a streaming parser such as formidable:
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from "next";
import formidable from "formidable";
export const config = { api: { bodyParser: false } };
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") return res.status(405).end();
const form = formidable({ maxFileSize: 5 * 1024 * 1024 });
try {
const [fields, files] = await form.parse(req);
return res.status(200).json({ received: Object.keys(files) });
} catch {
return res.status(400).json({ error: "Invalid multipart/form-data body" });
}
}
This snippet follows the promise-based API of formidable v3. If you’re on an older version, form.parse takes a callback instead.
What if my route forwards the upload to another service?
Proxying is the second most common place this breaks. There are two clean patterns, depending on how you build the outgoing request.
With native fetch and FormData, pass the form straight through and don’t touch the header:
export async function POST(request: Request) {
const incoming = await request.formData();
const upstream = await fetch("https://files.internal/upload", {
method: "POST",
body: incoming, // fetch generates a new boundary
});
return new Response(upstream.body, { status: upstream.status });
}
With the form-data npm package and a Node HTTP client such as axios, use getHeaders() to get the matching header, and merge it instead of overwriting your own headers:
import FormData from "form-data";
import axios from "axios";
const form = new FormData();
form.append("file", buffer, { filename: "report.pdf", contentType: "application/pdf" });
await axios.post("https://files.internal/upload", form, {
headers: { Authorization: `Bearer ${token}`, ...form.getHeaders() },
});
Pro Tip: Don’t mix the
form-datapackage with nativefetch. They use differentFormDataimplementations, and the combination is a frequent source of bodies that arrive empty or mangled. Pick one stack per request.
How do I test an upload endpoint in Burp Suite?
Burp Suite is excellent for probing upload endpoints, but multipart bodies are easy to break by hand. A workflow that keeps requests valid:
- Capture a real upload through the Burp proxy and send it to Repeater.
- Keep the boundary consistent. If you edit the body, every delimiter line must still be two hyphens plus the boundary from the
Content-Typeheader, and the last one must end with--. - Keep CRLF line endings between headers and parts. Some editors normalize them and corrupt the body.
- Let Burp update
Content-Length. Repeater has an option to do this automatically. Leave it on, because a stale length causes truncated or hanging requests that look like boundary errors.
Then run the cases that matter for security:
- A file with a changed extension and a mismatched part
Content-Type. - A file over your size limit.
- A request with the
boundaryparameter removed from the header. - A truncated body without the closing delimiter.
For the last two, the expected result is a clean 400. If you get a 500 with a stack trace, you’ve found a handling problem worth fixing.
Pro Tip: Never trust the part’s
Content-Typeorfile.type. Both are client-controlled. Check the actual file signature on the server before you store or process anything.
What content-type findings does Burp actually report?
This is where people often get confused. Burp Scanner’s content-type issues are about responses, not about the multipart request you send. The ones you’re most likely to see on a Next.js API are:
- Content type incorrectly stated: the response claims one type, but the body looks like another.
- Content type is not specified: the response has a body but no
Content-Typeheader. - Multiple content types specified: the response carries conflicting
Content-Typevalues.
According to PortSwigger’s issue definitions, these matter mostly when the affected resource is dynamically generated, user-uploaded, or contains user input, and the recommended remediation is a single, correct Content-Type header plus X-Content-Type-Options: nosniff.
In Next.js, you get most of this right by returning proper responses:
// ✅ Sets Content-Type: application/json
return NextResponse.json({ ok: true });
And you can add nosniff to all API routes in next.config.js:
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/api/:path*",
headers: [{ key: "X-Content-Type-Options", value: "nosniff" }],
},
];
},
};
If your upload endpoint echoes a stored file back, always serve it with the correct type, or with application/octet-stream and a Content-Disposition: attachment header when you don’t need the browser to render it.
FAQ
Why does my upload work in the browser but fail in curl or Postman?
Usually because of a manual header. With curl -F, curl builds the multipart body and the boundary for you. If you also pass -H "Content-Type: multipart/form-data", you override the generated value and lose the boundary. Remove the header and let the tool set it.
Should I ever set Content-Type myself when sending FormData with fetch?
No. Leave it out and let fetch generate the header with the boundary. Setting it manually is the single most common cause of this error.
Does the boundary have to start with dashes?
No. Browsers conventionally generate values with leading dashes, but the requirement comes from the multipart grammar (RFC 2046), not from a dash prefix. What matters is that the header and body agree and that the string doesn’t appear inside the content.
How do I return 400 instead of 500 for a malformed upload?
Wrap request.formData() in a try/catch and return a 400 response with a short error message. Don’t forward the raw parser error to the client.
Can I trust file.type?
No. It comes from the client and can be changed freely. Validate the file’s actual signature on the server, and restrict allowed types with an allow-list.
Conclusion
If Next.js 14 can’t parse your multipart upload, check the boundary first. Nine times out of ten, the fix is to remove a manually set Content-Type header and let fetch or your multipart library generate it.
My takeaways:
- Never hand-write the
Content-Typefor multipart bodies. Let the client or library generate the boundary. - Return
400for malformed uploads and validate size and file type on the server. - Use the right parser for your router:
request.formData()in the App Router,formidablewithbodyParser: falsein the Pages Router. - Test with Burp Repeater and keep boundaries, line endings, and
Content-Lengthconsistent. - Fix response content-type findings with correct headers and
X-Content-Type-Options: nosniff.
Small details like these are what separate an upload endpoint that merely works from one that holds up under a security scan.
Want more practical Next.js and security fixes like this one? Keep following SpiritCode.

