Reading the Body Once: json(), formData(), and the Stream You Cannot Read Twice
A mobile client posts an order to /api/orders and your handler dies with SyntaxError: Unexpected token < in JSON at position 0. That "<" is the giveaway: the client sent HTML, or a form, or nothing, and request.json() tried to parse it as JSON anyway. Reading a body correctly starts with matching the reader to the content type.
The incoming argument is a standard Web Request, so every platform body method works unchanged in route handlers:
| Content type | Read it with | What you get |
|---|---|---|
application/json |
await request.json() |
parsed object (throws if invalid) |
application/x-www-form-urlencoded |
await request.formData() |
FormData of fields |
multipart/form-data |
await request.formData() |
fields + files as File objects |
text/plain, CSV, webhook payloads |
await request.text() |
raw string |
| anything (binary) | await request.arrayBuffer() |
ArrayBuffer |
| streaming | request.body |
a ReadableStream |
export async function POST(request: NextRequest) {
const type = request.headers.get('content-type') ?? '';
if (!type.includes('application/json')) {
return apiError(415, 'unsupported_media_type', 'Send application/json');
}
let body: unknown;
try {
body = await request.json();
} catch {
return apiError(400, 'invalid_json', 'Body is not valid JSON');
}
const parsed = OrderSchema.safeParse(body);
if (!parsed.success) {
return apiError(422, 'validation_failed', zodIssues(parsed.error));
}
return NextResponse.json(await createOrder(parsed.data), { status: 201 });
}
The status codes are deliberate: 415 for the wrong media type, 400 for a body that is not even parseable, 422 for well-formed JSON that fails your schema. Each one is a distinct client bug, and distinguishing them turns a day of debugging into a glance at the status line.
Validation is not optional polish. await request.json() hands you an unknown value; anything you touch before a schema check is a bet that the client is well behaved. A safeParse-style check also lets you report every failing field at once — errors: [{ path: 'email', message: 'invalid format' }, ...] — instead of making the client fix one problem per round trip. And when the endpoint authenticates by cookie, verify the Origin header on state-changing methods: a cross-site POST arrives with the user's cookies attached, which is exactly the CSRF setup that sameSite exists to blunt.
formData() deserves one warning about scale: it buffers the entire multipart body in memory before you see a byte. Fine for an avatar; a memory incident for a gigabyte upload — and platform request-body caps (Vercel allows 4.5 MB on the Hobby plan, for instance) make large transfers fail anyway. Big files belong in a presigned object-storage upload, with your handler only recording the resulting key. The File objects you do receive expose name, type, size, and arrayBuffer(), which is everything a normal upload endpoint needs.
The trap that produces real incidents: a request body can be consumed only once. request.body is a one-shot ReadableStream — after json() reads it, a second read yields an error or an empty stream. That matters when two layers both need the payload: an auth helper wants the body for a claim check and the handler wants it for the order. The platform answer is request.clone(), which tees the stream so both copies read independently. Keep clones few — each one buffers memory — and prefer passing parsed data down instead. You will meet the clone pattern again in Verifying Webhook Signatures, where signature verification genuinely requires the untouched raw bytes alongside the parsed event.
One CRUD Route, Five Semantics: Query Strings, Idempotency, and the 405 Contract
A single /api/orders endpoint usually serves more than GET and POST. PUT replaces a resource, PATCH merges fields into it, DELETE removes it — and the method you dispatch on is the contract that tells clients which of those operations they are invoking. Export each method as its own function and share validation helpers rather than branching on request.method inside one function; the exports are the contract.
Query strings do not arrive as a props object in route handlers — parse them from the URL:
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = Math.min(Number(searchParams.get('limit') ?? 20), 100);
const cursor = searchParams.get('cursor'); // keyset pagination
const status = searchParams.get('status');
return NextResponse.json(await listOrders({ limit, cursor, status }));
}
The Math.min(..., 100) is not defensive noise: an unbounded limit turns one curious client into a denial-of-service against your database. Defaults plus hard caps belong in every list endpoint.
Now the semantics that separate careful APIs from sloppy ones:
export async function PUT(request: NextRequest, { params }) {
const { id } = await params;
const body = OrderSchema.parse(await request.json()); // full replacement
return NextResponse.json(await replaceOrder(id, body)); // same body, same result on retry
}
export async function PATCH(request: NextRequest, { params }) {
const { id } = await params;
const body = PartialOrderSchema.parse(await request.json()); // changed fields only
return NextResponse.json(await mergeOrder(id, body));
}
export async function DELETE(request: NextRequest, { params }) {
const { id } = await params;
await deleteOrder(id);
return new NextResponse(null, { status: 204 }); // no body on success
}
PUT is idempotent — repeating it leaves the same state — while POST is not, which is why a mobile client on a flaky network can safely retry a PUT after a timeout but must ask before re-issuing a POST. PATCH has no idempotency guarantee; incrementing a counter field twice is exactly the failure the distinction exists to describe. DELETE on an already-deleted resource should return 204 again, not 404, if you want safe retries.
For creates that must survive flaky networks, the industry answer is the Idempotency-Key header: the client generates one UUID per logical operation, you atomically claim it (Redis SET key value NX EX 86400, or a unique index in Postgres) before doing any work, and a repeat with the same key returns the stored first response instead of creating a second order. One atomic statement gives you the guarantee; a check-then-insert with a race window does not.
Next.js also derives some methods for you: HEAD is served from your GET with the body stripped, and a default OPTIONS response advertises the allowed methods, so preflights for a simple same-origin API succeed without an explicit export. Write your own OPTIONS only when you need custom CORS headers — and then echo the requested origin and methods exactly, because a wildcard * cannot be combined with credentials.
Finally, the 405 contract. Next.js answers unexported methods with 405 and an Allow header automatically, but return it explicitly when the handler has a precondition — for example, refusing writes during a maintenance window:
return new NextResponse(null, {
status: 405,
headers: { allow: 'GET, POST', 'x-robots-tag': 'noindex' },
});
Clients and proxies route on Allow; a 405 without it is a bug report waiting to happen. Pair the method contract with a consistent error envelope from the first chapter and you have an API a stranger can integrate with from the OpenAPI description alone.
Quiz
1. A signature-verification helper needs the raw request body and your handler also needs the parsed JSON. What is the correct approach?
2. In a Next.js 15 route handler, what is the difference between request.cookies.get('sid') and (await cookies()).get('sid')?
3. A client uploads an image with multipart/form-data. Which call gives you the file?
4. A GET handler parses limit = Number(searchParams.get('limit') ?? 20) with no upper bound. What is the concrete risk?
Flashcards
Question
Which body-reading method matches which content type?
Click to reveal answer
Answer
application/json → await request.json(); form types (urlencoded and multipart) → await request.formData(); text/CSV/signature payloads → await request.text(); binary → arrayBuffer(); streaming → request.body (ReadableStream). Matching the reader to the header avoids SyntaxError-on-HTML bugs.
Question
Can a request body be read twice? How do two consumers both get it?
Click to reveal answer
Answer
No — the body is a one-shot ReadableStream and a second read yields nothing usable. Use request.clone() so each consumer reads an independent copy, and keep clones minimal since each buffers the payload in memory.
Question
request.cookies vs cookies() from next/headers — what differs in a route handler?
Click to reveal answer
Answer
request.cookies (on NextRequest) only reads incoming cookies and needs no import. cookies() is async in Next 15+, reads the same incoming cookies, and is the only one that can set or delete cookies — mutation is allowed in route handlers and server actions.
Question
PUT vs PATCH vs POST — idempotency semantics an interviewer expects you to state?
Click to reveal answer
Answer
PUT replaces the whole resource and is idempotent (safe to retry, same end state). PATCH merges partial updates and is not idempotent. POST creates and is not idempotent — clients on flaky networks must not blindly retry it. DELETE repeated should return 204 again, not an error.
Question
Which status codes belong to body-handling failures in an API?
Click to reveal answer
Answer
415 Unsupported Media Type for the wrong content-type; 400 Bad Request for an unparseable body (invalid JSON); 422 Unprocessable Entity for well-formed data that fails schema validation. Distinguishing them localizes the client bug from the status line alone.
Revision Notes
Key Takeaways
- 1. Route handlers receive a standard Request, so json/formData/text/arrayBuffer/stream all work — the skill is picking the reader that matches the content type
- 2. The body is a one-shot stream: a second read fails, and request.clone() is the platform answer when auth or logging also needs the payload
- 3. request.cookies and cookies() read the same incoming header, but only cookies() (async in 15+, removed sync form in 16) can set or delete
- 4. Unclamped query parameters flow into your database — every list endpoint needs defaults plus hard caps
- 5. Method semantics are the API contract: 201 create, idempotent PUT, 204 delete, 405 with Allow — clients retry correctly only if you do
Interview Tips
- • "How do you read a POST body in a route handler?" — name the content-type → reader mapping and the 415/400/422 ladder; mentioning the one-shot stream shows you have debugged real payloads
- • "Where do you get the auth token?" — Authorization header via request.headers.get, or a session cookie; state that both are attacker-controllable and verification is a separate step
- • "Why is PUT retried and POST not?" — idempotency; add that DELETE should return 204 again on repeat, and PATCH increments break under blind retries
- • "What breaks when you read the body twice?" — the stream is exhausted; describe clone() and the memory trade-off of buffering large uploads
Cheat Sheet
Handling Request Methods
Body readers:
| Content-Type | Call | Failure mode |
|---|---|---|
| json | await request.json() |
throws on invalid JSON → 400 |
| form / multipart | await request.formData() |
wrong field names → 422 |
| text / raw | await request.text() |
none |
| binary | await request.arrayBuffer() |
— |
| stream | request.body |
read once only |
request.clone() tees the stream for a second consumer (webhook signatures). |
Headers & cookies:
request.headers.get('authorization') // case-insensitive, single value
request.cookies.get('sid')?.value // read-only, no import
await cookies() // next/headers; read + set/delete; async in 15+
Set cookies with { httpOnly, secure, sameSite: 'lax', path: '/' }.
Query strings: request.nextUrl.searchParams; always default and clamp (Math.min(n, 100)).
Method semantics: POST create → 201 · PUT replace (idempotent) · PATCH merge · DELETE → 204, body must be null · missing method → 405 + Allow.
Body error ladder: 415 wrong type → 400 unparseable → 422 schema failure.