Skip to content
Chapter 46Lesson 2

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() reads multipart/form-data for file uploads.
  • request.body is a ReadableStream , 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.

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.

POST /api/invoices/[invoiceId]/line-items
1
params ParamsSchema.safeParse
cheapest
2
headers HeadersSchema.safeParse
cheap
3
query QuerySchema.safeParse
cheap
4
body BodySchema.safeParse
most expensive
business logic — not yet

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.

POST /api/invoices/[invoiceId]/line-items
params ParamsSchema.safeParse
cheapest
2
headers HeadersSchema.safeParse
cheap
3
query QuerySchema.safeParse
cheap
4
body BodySchema.safeParse
most expensive
business logic — not yet

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.

POST /api/invoices/[invoiceId]/line-items
params ParamsSchema.safeParse
cheapest
headers HeadersSchema.safeParse
cheap
3
query QuerySchema.safeParse
cheap
4
body BodySchema.safeParse
most expensive
business logic — not yet

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.

POST /api/invoices/[invoiceId]/line-items
params ParamsSchema.safeParse
cheapest
headers HeadersSchema.safeParse
cheap
query QuerySchema.safeParse
cheap
4
body BodySchema.safeParse
most expensive
✓ business logic runs

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.

POST /api/invoices/[invoiceId]/line-items
params ParamsSchema.safeParse
cheapest
2
headers HeadersSchema.safeParse
cheap
3
query QuerySchema.safeParse
cheap
4
body BodySchema.safeParse
most expensive
400 · body never read

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.

1 / 1

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.

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.

Booting type-checker…
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}

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:

  • type is a stable URI that is the machine-readable error code, one URI per error class, pointing at that class’s docs page.
  • title is the human-readable name of the error class, the same for every instance.
  • status is the HTTP status code, repeated in the body.
  • detail is the message specific to this occurrence.
  • instance is a URI identifying this particular occurrence.

You can also attach an extension member , and we use exactly one, errors, for per-field validation messages.

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.

1 / 1

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.

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.

ConcernServer Action (Result)Route handler (Response)
Transportplain JS objectHTTP message body
Success shape{ ok: true, data }2xx status + JSON body
Error envelope{ ok: false, error }application/problem+json
Per-field errorserror.fieldErrorserrors extension, same Record<string, string[]>
Human messageerror.userMessagedetail
Machine codeerror.codetype 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 Request
Content-Type: application/json
400 Bad Request
Content-Type: application/problem+json
422 Unprocessable Entity
Content-Type: application/problem+json
422 Unprocessable Entity
Content-Type: application/json

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 in lib/schemas/invoice.ts.
  • The pure mutator, createInvoice(input), lives in lib/invoices.ts. It takes the parsed, typed input, does the database work, and returns the created entity. No Request, Response, or FormData touches 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.

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
    • invoices.ts the pure mutator createInvoice(input), no Request/Response
    • Directoryapi/
      • problem.ts the problem() and parseOr422() helpers
  • Directoryapp/
    • Directoryinvoices/
      • actions.ts the action seam, FormDataResult
    • Directoryapi/
      • Directoryinvoices/
        • route.ts the handler seam, JSON → Response

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.

Await and parse the path params — the cheapest gate.
Parse the JSON body with parseOr422 — short-circuits to a 422 Problem on failure.
Call the shared mutator with the parsed, typed input.
Validate the result against the response schema with parse.
Return NextResponse.json(...) with the success status.

References worth keeping open while you author your own contracts.