Skip to content
Chapter 46Lesson 2

Wire contracts as Zod schemas

Use Zod to define and 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, the action ran, and it handed back a Result the form destructured, so the compiler checked the whole boundary for you. A route handler has none of that safety net. 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. There is no implicit shape, no typed call site, and no compiler checking the boundary. So with the compiler gone, what keeps the contract honest? The answer is the one you already met for Server Actions: Zod is the single source of truth. The difference is that here it runs in both directions. It validates what comes in, and it describes and validates what goes out.

By the end you’ll be able to author a complete route-handler contract: a Zod schema for each input source, a typed response schema, an RFC 9457 Problem Details error body, and the two helpers that make the first three lines of every handler identical. The payoff is the last section, where a single create-invoice operation is served by both a Server Action and a public endpoint, sharing one schema and one mutator. Two seams, one trust posture: the same idea from the last chapter, now made concrete on the wire.

One sentence underpins the rest of the lesson. 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 is not one blob. It arrives as four separate channels, and a handler reads from whichever ones it needs: the path params baked into the URL segments, the headers that carry metadata, the query string after the ?, and the body. Each channel is untrusted wire data, so each gets its own Zod schema and its own safeParse call. This is the inbound half of the contract, the expanded “parse” seam from the five-seam model in the last lesson.

Here is each source, its accessor, and the schema that guards it.

Path params come in 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 gotcha you met in the last lesson. The schema is usually a single format check, z.object({ invoiceId: z.uuid() }). Treat that UUID check as the cheapest “is this even a valid request” gate: a malformed id is a request you can reject before touching anything expensive.

Headers that carry data are read one at a time with request.headers.get(...): the Idempotency-Key , Content-Type, Accept, If-None-Match. You parse them against a HeadersSchema, but only the one or two your handler actually relies on, not the dozens a browser sends. Most handlers parse zero or one header explicitly.

The query string lives at request.nextUrl.searchParams, a URLSearchParams instance, parsed against a QuerySchema. One contract-level point matters here: searchParams values are always strings, so z.coerce and z.preprocess bridge them to numbers, dates, and booleans. This is the same coercion you used at the FormData boundary, just from a different source. The full filter-and-sort treatment for list endpoints is its own lesson later in this chapter.

The body has four accessors, and which one you reach for is itself a decision:

  • await request.json() parses typed JSON. This is 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; we’ll verify those signatures when we get to webhooks. For now, just know text() exists for when you need the bytes rather than the parsed object.
  • await request.formData() reads multipart/form-data , for multipart 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 writing that schema with z.strictObject, not z.object. On a request body, an unexpected key is almost always a client that’s confused about your contract, whether from a typo, a stale field, or a misremembered name, and 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. The reason is the difference between rejecting junk early and doing pointless work. A malformed UUID in the path is the cheapest possible “no,” so it goes first: the handler returns 400 without ever awaiting or even reading the body. There is no reason to deserialize a megabyte of JSON for a request whose id was never going to be valid.

The point to hold onto: 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() }). This is the cheapest “is this even a valid request” check, so it goes first: a malformed id fails here, returns 400, and nothing below ever 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. searchParams 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 on the request, 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 in the path fails gate 1 and the handler returns 400 immediately. Gates 2 through 4 stay dimmed and untouched. The body is never read, so the handler never paid to deserialize a payload for a request whose id was never going to be valid.

Now the same idea as code. This skeleton is the inbound-contract shape you’ll reproduce in every handler: a safeParse per source stacked at the top, each one short-circuiting, and then a comment marking where the actual work begins. The parseFailed(...) calls are a placeholder for “return the error response.” A couple of sections from now you’ll fold the parse-and-short-circuit pair into a single helper, and this gets even tighter.

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)
}

Await and 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 we 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(). This is 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 worth restating, because on a public endpoint it stops being a style preference and becomes non-negotiable: at the wire boundary you use safeParse, never parse. You know the distinction. parse throws on bad input, while safeParse returns a result you branch on. The reason it matters here is that parse on hostile input throws an uncaught error, which the framework turns into a 500. That is both a worse experience for the caller, since a 500 says “we broke” when the truth is “you sent garbage,” and a source of noise in the 5xx alerts you’ll wire up for real failures later. 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”

Here’s the part that most separates a real route handler from “just return some JSON,” and it’s the least intuitive idea in the lesson, so take it slowly. A response is also a contract. The shape you send back is a promise to whoever consumes the endpoint, and like any promise on an untyped wire, it needs something enforcing it. That something is a response schema.

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']),
});

Now the interesting move. The handler doesn’t just hand its data to NextResponse.json(...); it passes the data through the schema first:

return NextResponse.json(invoiceResponseSchema.parse(data));

That parse is doing real work, not decoration. It validates the response on the way out. To see why that matters, picture the lazy version: the handler loads an invoice row from the database and does NextResponse.json(invoiceRow). The row is a database object, so it carries internalNotes, createdBy, maybe a costBasis your finance team logs. None of those were ever part of the public contract, and now every one of them is crossing the wire to a partner integration. That is not a hypothetical; it’s the single most common way private data leaks out of an API. The response schema is the allowlist that makes it impossible: 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”

You learned the rule one section ago, that untrusted input gets safeParse, and now I’m telling you to parse the response. That isn’t a contradiction; it’s the same rule read in the other direction, and getting it straight is the subtle part.

Inbound data comes from the outside world. It’s untrusted, a bad shape is the client’s mistake, and the right answer is a polite status code. So you safeParse, branch, and respond. Outbound data is something your own handler constructed. If it doesn’t match the response schema, that’s not the client’s fault; it’s 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, because that is exactly the signal you want: the server is broken, fix it. It’s the same “throw at the framework edge for impossible situations” rule from the Server Action chapter, now applied to the response. So when a reviewer sees parse on the way out, they shouldn’t reach to “fix” it to safeParse. The throw is the point.

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 single response. For a public or partner-facing API, that cost buys you a contract that can’t silently drift, and it’s worth paying. For an internal-only handler, one your own typed code calls and nobody outside the building touches, the consumer is already a TypeScript caller, the leak risk is lower, and the runtime cost usually isn’t justified. So the call an experienced engineer makes is a simple split: validate the response on the way out for public APIs; type the return (no runtime parse) for internal handlers. The three positions below make the difference concrete.

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. This is the single most common way private data leaks out of an API.

Two more threads before we move on. First, the type. type InvoiceResponse = z.infer<typeof invoiceResponseSchema> is the type an external client codes against: one declaration, one source of truth, the same principle you’ve used for inputs since you first met Zod, now living on the wire boundary. And if your API ever genuinely leaves the codebase, as a published 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. That’s the escape hatch, named once and gated on a real question: is this API published externally? Most SaaS products ship a README in year one, not an OpenAPI document, so we won’t build it. Just know the schema is what feeds it when the day comes.

Now try the allowlist yourself. The exercise below hands you a loose starter schema and runs three shapes through it: a clean public response, a database row carrying a stray internalNotes field, and a response missing a required field. Tighten the schema so it accepts the first and rejects the other two. Watch the inferred type resolve as you go; that’s the type a client would see.

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 they need a consistent one. If every handler invents its own error shape, a client has to write a different error renderer per endpoint, and your API has no contract on its worst day, the day something fails. The web already standardized the answer, and you’ve met it from the other side: RFC 9457 Problem Details. In an earlier chapter you read these bodies as a consumer; now you write them.

Every error response is sent as RFC 9457 application/problem+json , and that content type genuinely matters, because generic HTTP tooling keys off it to recognize “this is a structured error.” The body carries five core fields:

  • type is a stable URI that is the machine-readable error code. There is one URI per error class, and the docs page it points at is where that class is documented.
  • 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.

Beyond those five, you can attach an extension member , and we’ll use exactly one, errors, to carry per-field validation messages.

You don’t want handlers hand-assembling that five-field object, because the moment two handlers build it slightly differently, your error contract has drifted and the client’s single error renderer breaks. So every error response goes through one helper. A problem(status, code, options?) function lives in /lib/api, takes an HTTP status and your internal error code, and returns a NextResponse with the correct Content-Type, the matching status, and a typed body. One helper, every error, zero drift.

The other half of the boilerplate is the validation case. When a body safeParse fails, you return 422 Unprocessable Entity, the status for “this was valid JSON, but it failed the schema.” The distinction between 400 and 422, and the full status table, is the next lesson’s job; here we just need this one case. You don’t want to write the same if (!result.success) return problem(...) check at the top of every handler, so it gets its own helper too: parseOr422(schema, input) either returns the parsed value or short-circuits straight to the Problem response. The handler body collapses to a single readable line:

const body = parseOr422(bodySchema, await request.json());

How does one line both return a value and short-circuit on failure? The honest answer is that parseOr422 throws a Response on failure, which the handler’s outer boundary returns. That is a deliberate teaching simplification, since real error plumbing might return a discriminated result and let the caller decide, but throwing a Response keeps the call site to one line, and the one-line call site is what we’re after.

Here is the problem helper and the validation error path in one place. The two details that carry the weight are the content type, the easy one to forget, and the flat field-error shape.

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 takes a status, an internal code, and options. Two positional params, 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 is what makes the body a recognized Problem document instead of opaque JSON, and forgetting it is the easy mistake to make.

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 stable, 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 that maps each error code to its class-level human title, a const record like { 'validation-failed': 'Validation failed' }. It is the one place the human-readable class name lives.

That z.flattenError(result.error).fieldErrors line is load-bearing, and not just inside this handler. It produces a flat Record<string, string[]>, mapping field name to a list of messages, and that is the exact same shape a Server Action’s Result returns in its error.fieldErrors, which is the exact same shape the React Hook Form applyServerErrors helper consumes to paint errors onto inputs. Three layers, one vocabulary. The payoff is concrete: 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. One renderer, both seams.

(A note for when you read the Zod docs: Zod offers both z.flattenError and z.treeifyError. The course’s Result contract is the flat shape, so we use flattenError everywhere the field errors need to interoperate. Reach for treeifyError only when a form is genuinely nested and shaped to match, which is not the case here.)

So the action and the handler share field names, but they are not the same object, and it’s worth seeing the two side by side. A Server Action returns a Result, a plain JavaScript object the React form layer reads directly. A route handler returns a Response, an HTTP message the client decodes off the wire. Different envelopes, deliberately shared vocabulary.

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 that falls out of the table: 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 will reject on sight. The first is returning an arbitrary JSON error shape instead of application/problem+json: the shared renderer can’t read it, and the API has lost its error contract. The second is reusing the same 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 tell them apart or branch on them. One URI per class, with the per-instance detail in detail.

Quick check on the one decision from this section you have to get right.

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

Everything so far assembles into one shape, and this is where the lesson pays off. Picture a real requirement: a createInvoice operation needs to work from the dashboard form and be callable as a public endpoint by a partner integration. The lazy approach writes it twice, copying validation and business logic into both the action and the handler, and the two copies start drifting the day someone changes a rule in one and forgets the other. That’s the bug you’re designing out.

The disciplined approach is three pieces and two thin seams. One input schema, shared. One pure mutator, shared. Two seams that differ only in wire format.

  • The shared schema, createInvoiceSchema, lives in lib/schemas/invoice.ts, the same file the form already imports.
  • 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. It knows nothing about Request, Response, or FormData; no wire format touches it. That’s the “thin seams, pure /lib” principle made physical, and it’s the same split you’ve built before.
  • The two seams each parse their own wire format, call the identical mutator, and serialize their own way out. The action parses FormData and returns a Result the form destructures; the handler parses JSON and returns a Response the client decodes.

Put the two seams next to each other and the lesson is the diff. Everything outside the highlighted mutator call is wire-format plumbing, and 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 physical layout enforces the logical one. The schema and the mutator sit in /lib, where both seams import them; 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() + 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.

The point to carry out of this lesson: 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 canonical 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.

Three references worth keeping open while you author your own contracts.