Wire contracts as Zod schemas
Use Zod to enforce a route handler's API contract, validating untrusted requests on the way in and guarding responses on the way out.
In a form, the Server Action was a typed function call: TypeScript knew the argument shape and handed back a Result the form destructured, so the compiler checked the boundary for you.
A route handler has none of that.
The caller might be a Python script, a mobile app, or someone testing with curl, and all your handler receives is a NextRequest whose body is unknown bytes.
Zod becomes the single source of truth, and here it runs in both directions: validating what comes in and validating what goes out.
You’ll build a complete route-handler contract: a Zod schema per input source, a typed response schema, an RFC 9457 Problem Details error body, and the two helpers that start every handler. The payoff is the last section, where one create-invoice operation is served by both a Server Action and a public endpoint, sharing one schema and one mutator.
The body is unknown until you parse it, and the type TypeScript infers at the wire boundary is only true because the schema makes it true.
route.ts reads four input sources, parsed cheapest first
Section titled “route.ts reads four input sources, parsed cheapest first”A request arrives as four separate channels, and a handler reads whichever it needs: the path params in the URL segments, the headers carrying metadata, the query string after the ?, and the body.
Each is untrusted wire data, so each gets its own Zod schema and its own safeParse.
This is the inbound half of the contract.
Path params arrive through the handler’s second argument.
In a file at app/api/invoices/[invoiceId]/line-items/route.ts, the [invoiceId] segment lands in params.
In Next.js 16 params is a Promise, so you await it before parsing.
The schema is usually a single format check, z.object({ invoiceId: z.uuid() }): the cheapest “is this even a valid request” gate, rejecting a malformed id before touching anything expensive.
Headers are read one at a time with request.headers.get(...): the Idempotency-Key , Content-Type, Accept, If-None-Match.
You parse only the one or two your handler relies on against a HeadersSchema, not the dozens a browser sends.
Most handlers parse zero or one.
The query string lives at request.nextUrl.searchParams, a URLSearchParams instance, parsed against a QuerySchema.
Its values are always strings, so z.coerce and z.preprocess bridge them to numbers, dates, and booleans, the same coercion you used at the FormData boundary.
The full filter-and-sort treatment for list endpoints comes later in this chapter.
The body has four accessors, and which you reach for is itself a decision:
await request.json()parses typed JSON, the default for almost every handler.await request.text()gives you the raw bytes, untouched. A webhook reads this, because its signature is computed over the exact bytes on the wire.await request.formData()readsmultipart/form-datafor file uploads.request.bodyis aReadableStream, used only when a payload is large enough that streaming earns its weight.
Whichever accessor you use, its output feeds a BodySchema.safeParse(...).
At the body boundary, default to z.strictObject, not z.object.
An unexpected key in a request body is almost always a confused client, a typo, a stale field, or a misremembered name, so z.strictObject rejects it with a 422 instead of silently swallowing it.
The extra key isn’t data; it’s a signal the client got something wrong, and you want them to hear it.
Why the order matters
Section titled “Why the order matters”The four sources are parsed in a fixed order, path params, then headers, then query, then body, cheapest disqualifier first, and the handler bails the instant one fails. A malformed UUID in the path is the cheapest possible “no,” so it goes first: the handler returns 400 without ever reading the body. There is no reason to deserialize a megabyte of JSON for a request whose id was never going to be valid.
So a handler’s first few lines are nothing but safeParse calls and short-circuits: no business logic, no database touch, no logging, until everything parses.
Watch a single request move through those gates.
Gate 1, params. Await and parse the path params with z.object({ invoiceId: z.uuid() }), the cheapest check, so it goes first.
A malformed id fails here, returns 400, and nothing below runs.
Gate 2, headers. The id passed, so move on.
Parse the one header this route relies on, request.headers.get('idempotency-key'), against the HeadersSchema.
Still no I/O, still no business logic.
Gate 3, query. Parse request.nextUrl.searchParams against the QuerySchema.
Values are always strings, so z.coerce bridges them to numbers, dates, and booleans on the way through.
Gate 4, body. Only now await request.json() and safeParse it against the BodySchema.
This is the most expensive read, which is exactly why it is gated behind every cheaper check.
Past it, every value is parsed and typed, and business logic runs.
The short-circuit. A bad UUID fails gate 1 and the handler returns 400 immediately. Gates 2 through 4 stay dimmed and untouched, so the handler never paid to deserialize a payload for a request whose id was never going to be valid.
The same shape as code: a safeParse per source stacked at the top, each short-circuiting, then a comment marking where the work begins.
The parseFailed(...) calls stand in for “return the error response.”
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/line-items'>,) { const path = ParamsSchema.safeParse(await params); if (!path.success) return parseFailed(path.error);
const key = HeadersSchema.safeParse({ idempotencyKey: request.headers.get('idempotency-key'), }); if (!key.success) return parseFailed(key.error);
const body = BodySchema.safeParse(await request.json()); if (!body.success) return parseFailed(body.error);
// Everything parsed. Business logic begins here, and only here. // → returns a typed Response (next sections)}The signature is NextRequest plus the { params } context, typed by the generated RouteContext helper.
params is a Promise in Next.js 16, so it has to be awaited before anything reads it.
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/line-items'>,) { const path = ParamsSchema.safeParse(await params); if (!path.success) return parseFailed(path.error);
const key = HeadersSchema.safeParse({ idempotencyKey: request.headers.get('idempotency-key'), }); if (!key.success) return parseFailed(key.error);
const body = BodySchema.safeParse(await request.json()); if (!body.success) return parseFailed(body.error);
// Everything parsed. Business logic begins here, and only here. // → returns a typed Response (next sections)}Parse the path params first, the cheapest gate. On failure, return immediately; nothing below runs.
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/line-items'>,) { const path = ParamsSchema.safeParse(await params); if (!path.success) return parseFailed(path.error);
const key = HeadersSchema.safeParse({ idempotencyKey: request.headers.get('idempotency-key'), }); if (!key.success) return parseFailed(key.error);
const body = BodySchema.safeParse(await request.json()); if (!body.success) return parseFailed(body.error);
// Everything parsed. Business logic begins here, and only here. // → returns a typed Response (next sections)}Read only the header this route needs and parse it.
Note we hand safeParse an object built from request.headers.get(...), not the raw Headers instance.
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/line-items'>,) { const path = ParamsSchema.safeParse(await params); if (!path.success) return parseFailed(path.error);
const key = HeadersSchema.safeParse({ idempotencyKey: request.headers.get('idempotency-key'), }); if (!key.success) return parseFailed(key.error);
const body = BodySchema.safeParse(await request.json()); if (!body.success) return parseFailed(body.error);
// Everything parsed. Business logic begins here, and only here. // → returns a typed Response (next sections)}The body parse runs last, after await request.json(), the expensive read gated behind every cheaper check.
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/line-items'>,) { const path = ParamsSchema.safeParse(await params); if (!path.success) return parseFailed(path.error);
const key = HeadersSchema.safeParse({ idempotencyKey: request.headers.get('idempotency-key'), }); if (!key.success) return parseFailed(key.error);
const body = BodySchema.safeParse(await request.json()); if (!body.success) return parseFailed(body.error);
// Everything parsed. Business logic begins here, and only here. // → returns a typed Response (next sections)}Past the last short-circuit, every value is parsed and typed. These comment lines are the boundary: business logic lives below them, never above.
One rule is non-negotiable on a public endpoint: at the wire boundary you use safeParse, never parse.
parse throws on bad input; safeParse returns a result you branch on.
On hostile input parse throws an uncaught error the framework turns into a 500, which misleads the caller, since a 500 says “we broke” when the truth is “you sent garbage,” and buries it among the 5xx alerts you’ll wire up for real failures.
Untrusted input never throws; it gets safeParsed and answered with a deliberate status.
The response schema validates what goes out
Section titled “The response schema validates what goes out”A response is a contract too: the shape you send back is a promise to whoever consumes the endpoint, and on an untyped wire it needs a response schema to enforce it. Declare the success shape the same way you declare an input:
export const invoiceResponseSchema = z.object({ id: z.uuid(), total: z.number(), status: z.enum(['draft', 'sent', 'paid']),});The handler doesn’t hand its data straight to NextResponse.json(...); it passes the data through the schema first:
return NextResponse.json(invoiceResponseSchema.parse(data));That parse validates the response on its way out.
Skip it and the handler ships a raw database row carrying internalNotes, createdBy, maybe a costBasis your finance team logs, fields that were never part of the public contract and are now crossing the wire to a partner integration.
This is the most common way private data leaks out of an API, and the response schema is the allowlist that stops it: a field the schema doesn’t declare doesn’t ship.
Why parse here, when you just learned safeParse
Section titled “Why parse here, when you just learned safeParse”One section ago, untrusted input got safeParse; now the response gets parse.
That’s the same rule read in the other direction.
Inbound data comes from the outside world, so a bad shape is the client’s mistake, and the right answer is a polite status code: safeParse, branch, respond.
Outbound data is something your own handler built, so a shape that doesn’t match the schema is a bug in your code, a field you forgot to map or a refactor that changed the shape.
A programmer error should throw, get caught at the framework’s error boundary, and surface as a 500, which is exactly the signal you want: the server is broken, fix it.
Validate for the public, type for the inside
Section titled “Validate for the public, type for the inside”Validating on the way out costs a runtime parse on every response.
For a public or partner-facing API, that buys a contract that can’t silently drift, and it’s worth paying.
For an internal-only handler that only your own typed code calls, the consumer is already a TypeScript caller and the leak risk is lower, so the runtime cost usually isn’t justified.
The rule: validate the response on the way out for public APIs; type the return (no runtime parse) for internal handlers.
const invoiceRow = await db.query.invoices.findFirst({ where: eq(invoices.id, path.data.invoiceId),});// invoiceRow carries every DB column: id, total, status,// internalNotes, createdBy, costBasis, ...return NextResponse.json(invoiceRow);Ships every column the row carries. internalNotes and createdBy cross the wire to a client that was never promised them, the most common way private data leaks out of an API.
return NextResponse.json(data satisfies InvoiceResponse);Right shape at compile time, but a later refactor can still ship a stray field at runtime. Acceptable for an internal handler whose callers are your own typed code.
return NextResponse.json(invoiceResponseSchema.parse(data));The schema is the allowlist. A stray field throws in review, not in production. This is the default for any public API.
The type comes from the same schema.
type InvoiceResponse = z.infer<typeof invoiceResponseSchema> is the type an external client codes against: one declaration, one source of truth, now living on the wire boundary.
And if the API is ever published as a REST surface or a partner SDK, that same schema is what an OpenAPI generator like next-openapi-gen reads to produce a machine-readable spec.
The exercise hands you a loose starter schema and runs three shapes through it: a clean public response, a database row with a stray internalNotes field, and a response missing a required field.
Tighten the schema so it accepts the first and rejects the other two, and watch the inferred type, the type a client would see, resolve as you go.
Tighten invoiceResponseSchema into the response allowlist: accept the public shape (id, total, status) and reject a database row carrying a stray internalNotes. Watch the ^? query — that's the exact type an external client codes against.
| Test scenario | Value | |
|---|---|---|
| clean public shape | {"id":"6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f","total":240,… | |
| leaking row (has internalNotes) | {"id":"6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f","total":240,… | |
| missing required field (no status) | {"id":"6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f","total":240} | |
Errors as RFC 9457 Problem Details
Section titled “Errors as RFC 9457 Problem Details”Success has a schema; errors need one too, and a consistent one. If every handler invents its own error shape, a client needs a different error renderer per endpoint. The web standardized the answer, RFC 9457 Problem Details: you read these bodies as a consumer in an earlier chapter; now you write them.
Every error response is sent as RFC 9457 application/problem+json .
The body carries five core fields:
typeis a stable URI that is the machine-readable error code, one URI per error class, pointing at that class’s docs page.titleis the human-readable name of the error class, the same for every instance.statusis the HTTP status code, repeated in the body.detailis the message specific to this occurrence.instanceis a URI identifying this particular occurrence.
You can also attach an extension member , and we use exactly one, errors, for per-field validation messages.
One helper for every error
Section titled “One helper for every error”If two handlers build that five-field object differently, the contract drifts and the client’s single renderer breaks, so every error response goes through one helper.
A problem(status, code, options?) function in /lib/api takes an HTTP status and your internal error code and returns a NextResponse with the correct Content-Type, matching status, and a typed body.
The other recurring case is validation.
When a body safeParse fails, you return 422 Unprocessable Entity, the status for “valid JSON, but it failed the schema.”
Rather than repeat if (!result.success) return problem(...) atop every handler, parseOr422(schema, input) returns the parsed value or short-circuits to the Problem response, collapsing the handler to one line:
const body = parseOr422(bodySchema, await request.json());It manages both because parseOr422 throws a Response on failure, which the handler’s outer boundary returns.
export const problem = ( status: number, code: string, options?: { detail?: string; errors?: Record<string, string[]> },) => NextResponse.json( { type: `https://api.acme.com/problems/${code}`, title: titleFor(code), status, detail: options?.detail, errors: options?.errors, }, { status, headers: { 'content-type': 'application/problem+json' } }, );
export const parseOr422 = <T>(schema: z.ZodType<T>, input: unknown): T => { const result = schema.safeParse(input); if (result.success) return result.data; const { fieldErrors } = z.flattenError(result.error); throw problem(422, 'validation-failed', { errors: fieldErrors });};The signature: a status, an internal code, then an options object, so the call site stays short.
export const problem = ( status: number, code: string, options?: { detail?: string; errors?: Record<string, string[]> },) => NextResponse.json( { type: `https://api.acme.com/problems/${code}`, title: titleFor(code), status, detail: options?.detail, errors: options?.errors, }, { status, headers: { 'content-type': 'application/problem+json' } }, );
export const parseOr422 = <T>(schema: z.ZodType<T>, input: unknown): T => { const result = schema.safeParse(input); if (result.success) return result.data; const { fieldErrors } = z.flattenError(result.error); throw problem(422, 'validation-failed', { errors: fieldErrors });};The content type. This single header makes the body a recognized Problem document instead of opaque JSON, and forgetting it is the easy mistake.
export const problem = ( status: number, code: string, options?: { detail?: string; errors?: Record<string, string[]> },) => NextResponse.json( { type: `https://api.acme.com/problems/${code}`, title: titleFor(code), status, detail: options?.detail, errors: options?.errors, }, { status, headers: { 'content-type': 'application/problem+json' } }, );
export const parseOr422 = <T>(schema: z.ZodType<T>, input: unknown): T => { const result = schema.safeParse(input); if (result.success) return result.data; const { fieldErrors } = z.flattenError(result.error); throw problem(422, 'validation-failed', { errors: fieldErrors });};The body, assembled in one place so every handler’s errors are shaped identically. The type URI is the machine-readable error code; errors is the one extension member we attach.
export const problem = ( status: number, code: string, options?: { detail?: string; errors?: Record<string, string[]> },) => NextResponse.json( { type: `https://api.acme.com/problems/${code}`, title: titleFor(code), status, detail: options?.detail, errors: options?.errors, }, { status, headers: { 'content-type': 'application/problem+json' } }, );
export const parseOr422 = <T>(schema: z.ZodType<T>, input: unknown): T => { const result = schema.safeParse(input); if (result.success) return result.data; const { fieldErrors } = z.flattenError(result.error); throw problem(422, 'validation-failed', { errors: fieldErrors });};parseOr422 flattens the Zod error to fieldErrors, a flat Record<string, string[]>. This exact shape is the bridge to the form layer, as the next section explains.
export const problem = ( status: number, code: string, options?: { detail?: string; errors?: Record<string, string[]> },) => NextResponse.json( { type: `https://api.acme.com/problems/${code}`, title: titleFor(code), status, detail: options?.detail, errors: options?.errors, }, { status, headers: { 'content-type': 'application/problem+json' } }, );
export const parseOr422 = <T>(schema: z.ZodType<T>, input: unknown): T => { const result = schema.safeParse(input); if (result.success) return result.data; const { fieldErrors } = z.flattenError(result.error); throw problem(422, 'validation-failed', { errors: fieldErrors });};On failure it throws the Problem Response, which the handler’s boundary returns. That’s what lets the call site read as one line.
titleFor(code) is a small in-module lookup mapping each error code to its human title, a const record like { 'validation-failed': 'Validation failed' }, the one place the human-readable class name lives.
The flat fieldErrors shape is the bridge
Section titled “The flat fieldErrors shape is the bridge”z.flattenError(result.error).fieldErrors produces a flat Record<string, string[]> mapping field name to a list of messages, the same shape a Server Action’s Result returns in error.fieldErrors, and the same shape the React Hook Form applyServerErrors helper consumes.
Because the handler and the action speak the same field language, the form’s error renderer just works when a route handler is the caller.
Zod offers both z.flattenError and z.treeifyError; the course’s Result contract is the flat shape, so use flattenError wherever field errors need to interoperate.
The action and the handler share field names, but not the same object: a Server Action returns a Result, a plain JavaScript object the React form layer reads directly, while a route handler returns a Response, an HTTP message the client decodes off the wire.
| Concern | Server Action (Result) | Route handler (Response) |
|---|---|---|
| Transport | plain JS object | HTTP message body |
| Success shape | { ok: true, data } | 2xx status + JSON body |
| Error envelope | { ok: false, error } | application/problem+json |
| Per-field errors | error.fieldErrors | errors extension, same Record<string, string[]> |
| Human message | error.userMessage | detail |
| Machine code | error.code | type URI |
Different envelopes, shared field vocabulary, so the form’s error renderer works for both.
The rule: stay HTTP-native at the handler boundary, stay JS-native at the action boundary, and share the field vocabulary in the middle.
Two mistakes a reviewer rejects on sight: returning an arbitrary JSON error shape instead of application/problem+json, which the shared renderer can’t read; and reusing one type URI for different error classes.
The URI is the code, so if “invoice not found” and “validation failed” share a URI, a client cannot branch on them: one URI per class, with the per-instance detail in detail.
A partner POSTs { "total": 240 } to your create-invoice endpoint. The bytes deserialize cleanly as JSON, but your BodySchema rejects them because the required status field is absent. Which status line and Content-Type does the handler send back?
400 Bad RequestContent-Type: application/json400 Bad RequestContent-Type: application/problem+json422 Unprocessable EntityContent-Type: application/problem+json422 Unprocessable EntityContent-Type: application/jsonapplication/problem+json. Ask two questions in order. Was the request parseable? Yes — it was valid JSON, so the problem isn’t malformed syntax (that’s 400); a request that deserialized but failed your schema is the textbook 422. Is this an error response? Yes — and every error on this API is a Problem document, so the body is application/problem+json, never plain application/json. Plain JSON would also strand parseOr422, whose whole job is to emit a Problem body. The full 400-vs-422 boundary and the rest of the status table are the next lesson’s job.One schema, one mutator, two callers
Section titled “One schema, one mutator, two callers”A createInvoice operation has to work from the dashboard form and be callable as a public endpoint by a partner integration.
Write it twice and validation and business logic live in both the action and the handler, then drift the day someone changes a rule in one and forgets the other.
The fix is one shared input schema, one shared mutator, and two seams that differ only in wire format.
- The shared schema,
createInvoiceSchema, lives inlib/schemas/invoice.ts. - The pure mutator,
createInvoice(input), lives inlib/invoices.ts. It takes the parsed, typed input, does the database work, and returns the created entity. NoRequest,Response, orFormDatatouches it. - The two seams each parse their own wire format, call the identical mutator, and serialize their own way out.
Below, everything outside the highlighted mutator call is wire-format plumbing; the highlighted line is the shared core, identical in both.
export const createInvoiceAction = async ( _prev: unknown, formData: FormData,) => { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err('validation', 'Check the fields below.', flatten(parsed.error)); } const input = parsed.data; const invoice = await createInvoice(input); revalidatePath('/invoices'); return ok(invoice);};Parses FormData, returns a Result the form destructures. err/ok are the Result helpers, and flatten is the course’s z.flattenError(...).fieldErrors projection.
export async function POST(request: NextRequest) { const input = parseOr422(createInvoiceSchema, await request.json()); const invoice = await createInvoice(input); return NextResponse.json(invoiceResponseSchema.parse(invoice), { status: 201, });}Parses JSON, returns a Response the client decodes. Same schema, same mutator. The seam is three lines because parseOr422 and invoiceResponseSchema.parse carry the contract.
The schema and the mutator sit in /lib, where both seams import them, so neither seam owns the contract or the logic.
Directorylib/
Directoryschemas/
- invoice.ts the shared input schema,
createInvoiceSchema
- invoice.ts the shared input schema,
- invoices.ts the pure mutator
createInvoice(input), noRequest/Response Directoryapi/
- problem.ts the
problem()andparseOr422()helpers
- problem.ts the
Directoryapp/
Directoryinvoices/
- actions.ts the action seam,
FormData→Result
- actions.ts the action seam,
Directoryapi/
Directoryinvoices/
- route.ts the handler seam, JSON →
Response
- route.ts the handler seam, JSON →
The schema and the mutator live in /lib; both seams import them. The physical separation enforces the logical one.
Any time a handler and an action would duplicate business logic, the shared mutator is the seam: the wire format is the variable, the logic is the constant.
Now reconstruct the handler from memory.
Drag the steps of a route handler’s POST body into the order they must run.
Order the body of a route handler's `POST`, from the first line to the last. Drag the items into the correct order, then press Check.
parseOr422 — short-circuits to a 422 Problem on failure. parse. NextResponse.json(...) with the success status. Going further
Section titled “Going further”References worth keeping open while you author your own contracts.