Short answer
TypeScript types disappear at runtime, so check data with a Zod schema wherever it enters your app, and write the types themselves as discriminated unions so impossible states cannot be represented. In a Next.js app the entry points are Server Actions, Route Handlers, external API and CMS responses, and environment variables. Write the schema once, infer the type from it, and have actions return a result union rather than throw.
Big TypeScript codebases rarely fail because a type annotation is missing. They fail because a type says something the runtime data does not: a form field that arrives as a string, an API that adds a variant, an environment variable that is unset in one region. The patterns below keep compile-time types and runtime data in agreement. They target Zod 4 (4.6 is the current release at the time of writing) and the Next.js 16 App Router.
Where the trust boundaries are
Inside your own code, TypeScript is enough. Wherever data crosses into the process, it is not, because the compiler has no way to see what actually arrives.
| Boundary | Where the data comes from | Why types alone fail |
|---|---|---|
| Server Actions | FormData or arguments from the browser |
Anyone can call them with any payload. Every value arrives as a string |
| Route Handlers | Request body, query, headers | Public HTTP endpoints. Webhooks send whatever the sender’s current version sends |
| External APIs and CMS | JSON over the network | Fields are added, renamed or come back null without notice |
| Environment | process.env |
Everything is string | undefined, and is wrong per environment |
| URL state | searchParams, dynamic params |
Users edit URLs. Crawlers send stale links |
Put a schema at each of these and nowhere else. Parsing data you produced yourself, between your own modules, only adds cost.
Discriminated unions instead of boolean flags
The most common modelling bug in React code is a set of independent flags describing what is really one state:
// Allows 16 combinations; only 4 are meaningful.
type Upload = {
isUploading: boolean;
isDone: boolean;
error?: string;
url?: string;
progress?: number;
};
Nothing stops isDone: true with an error and no url. Every consumer ends up defending against combinations that should be impossible. A discriminated union states the real shape:
type Upload =
| { status: 'idle' }
| { status: 'uploading'; progress: number }
| { status: 'failed'; error: string }
| { status: 'done'; url: string };
export function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function label(u: Upload): string {
switch (u.status) {
case 'idle': return 'Choose a file';
case 'uploading': return `${Math.round(u.progress * 100)}%`; // progress exists here
case 'failed': return u.error;
case 'done': return 'Uploaded';
default: return assertNever(u); // compile error if a variant is added and not handled
}
}
TypeScript narrows on the shared literal property (see the TypeScript handbook on discriminated unions), and the never check makes “add a variant” a compile-time task list. The same technique handles heterogeneous CMS content. The modular-blocks renderer in Contentstack and Next.js: building a composable front end is this pattern applied to page sections.
One schema, two types
Zod gives you the runtime check and the static type from a single definition, so the two cannot drift apart. There is one subtlety: when a schema coerces or transforms, its input type and output type are different.
import * as z from 'zod';
const Quantity = z.coerce.number().int().min(1).max(99);
type QuantityIn = z.input<typeof Quantity>; // unknown: what may arrive
type QuantityOut = z.output<typeof Quantity>; // number: what you get after parsing
// z.infer is an alias for z.output
Use z.output (or z.infer) for anything downstream of the parse. If you type a form’s props from the output type, you are claiming the browser sends numbers, and it never does.
Server Actions: parse, then return a result union
A Server Action is a public endpoint that happens to have a function signature. The Next.js authentication guide says to treat it that way: validate the input and authorise inside the action. Below, a checkout step where the shape depends on the delivery method, which is a natural fit for z.discriminatedUnion.
// app/checkout/schema.ts
import * as z from 'zod';
const contact = {
email: z.email({ error: 'Enter a valid email address.' }),
};
export const DeliverySchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('collect'),
...contact,
storeId: z.string().regex(/^st_[a-z0-9]+$/, { error: 'Choose a store.' }),
}),
z.object({
method: z.literal('courier'),
...contact,
line1: z.string().trim().min(3, { error: 'Enter the first line of the address.' }),
postcode: z.string().trim().toUpperCase().min(5, { error: 'Enter a postcode.' }),
}),
]);
export type Delivery = z.output<typeof DeliverySchema>;
export type DeliveryResult =
| { status: 'idle' }
| { status: 'invalid'; fieldErrors: Record<string, string[] | undefined> }
| { status: 'failed'; message: string }
| { status: 'saved'; quoteId: string };
// app/checkout/actions.ts
'use server';
import * as z from 'zod';
import { DeliverySchema, type DeliveryResult } from './schema';
import { verifySession } from '@/lib/dal'; // your data access layer
import { saveDelivery } from '@/lib/checkout';
export async function saveDeliveryStep(
_prev: DeliveryResult,
formData: FormData,
): Promise<DeliveryResult> {
const session = await verifySession(); // authorise inside the action
// Object.fromEntries keeps only the last value of repeated keys;
// use formData.getAll() for checkbox groups and multi-selects.
const parsed = DeliverySchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { status: 'invalid', fieldErrors: z.flattenError(parsed.error).fieldErrors };
}
try {
const quoteId = await saveDelivery(session.userId, parsed.data); // typed as Delivery
return { status: 'saved', quoteId };
} catch {
return { status: 'failed', message: 'We could not save this step. Please try again.' };
}
}
On the client, useActionState gets back the same union, and the narrowing works there too:
'use client';
import { useActionState } from 'react';
import { saveDeliveryStep } from './actions';
export function DeliveryForm() {
const [state, action, pending] = useActionState(saveDeliveryStep, { status: 'idle' });
const errors = state.status === 'invalid' ? state.fieldErrors : {};
return (
<form action={action}>
{/* ...fields... */}
{errors.postcode?.[0] && <p role="alert">{errors.postcode[0]}</p>}
{state.status === 'failed' && <p role="alert">{state.message}</p>}
<button disabled={pending}>Continue</button>
</form>
);
}
Why return instead of throw? Errors thrown from an action reach the nearest error boundary, so a validation failure would replace the form with an error screen. Expected outcomes such as invalid input, conflicts and permission denials belong in the return type. Save throwing for genuine bugs. Authentication is out of scope here, but if you rely on proxy (formerly middleware) for it, read why proxy must not be your only auth check.
External responses: parse at the fetch, decide how strict to be
Parse third-party JSON in the function that fetches it, so nothing downstream ever sees an unvalidated object. Then make two deliberate decisions:
- Unknown keys. In Zod 4,
z.object()strips unknown keys,z.strictObject()rejects them, andz.looseObject()passes them through. For APIs you don’t control, strict is usually wrong: a harmless added field would take your page down. - Unknown variants. A discriminated union rejects a discriminator value it doesn’t know. When the source is a CMS that editors can extend, parse each item on its own and drop or log the ones that fail, rather than failing the whole page.
const Block = z.discriminatedUnion('type', [HeroBlock, QuoteBlock, CardGridBlock]);
type Block = z.output<typeof Block>;
export function parseBlocks(raw: unknown[]): Block[] {
return raw.flatMap((item) => {
const result = Block.safeParse(item);
if (result.success) return [result.data];
console.warn('Dropping invalid block\n' + z.prettifyError(result.error));
return [];
});
}
Zod 4 discriminated unions also accept other discriminated unions as members, which is useful when a block type itself has variants.
Environment variables: fail at boot, not on the first request
// lib/env.ts
import 'server-only';
import * as z from 'zod';
const Env = z.object({
DATABASE_URL: z.url(),
SESSION_SECRET: z.string().min(32),
FEATURE_NEW_CHECKOUT: z.stringbool().default(false), // "true", "1", "on", "yes" ...
});
export const env = Env.parse(process.env);
A missing secret should stop the deployment, not cause a 500 on the first checkout an hour later. Keep this module server-only. Variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at build time, so they need their own small schema, and changing one means a rebuild.
Branded IDs
When everything is a string, passing an order ID where a user ID is expected compiles fine. A brand adds a type-only tag, with no runtime cost, which only a successful parse can produce:
export const OrderId = z.string().regex(/^ord_[a-z0-9]+$/).brand<'OrderId'>();
export type OrderId = z.output<typeof OrderId>;
declare function refund(id: OrderId): Promise<void>;
refund('usr_123'); // compile error
refund(OrderId.parse(params.orderId)); // fine, and checked at runtime
Zod 3 to Zod 4: what changes in this code
A lot of tutorials and AI-generated snippets still use Zod 3 idioms. Even the current Next.js authentication guide uses error.flatten(). Most still work in Zod 4 but are deprecated, according to the Zod 4 migration guide:
| Zod 3 habit | Zod 4 |
|---|---|
z.string().email(), .url(), .uuid() |
z.email(), z.url(), z.uuid() (top-level formats) |
{ message: '...' } |
{ error: '...' } (string or function) |
err.flatten() / err.format() |
z.flattenError(err) / z.treeifyError(err) |
.strict() / .passthrough() |
z.strictObject() / z.looseObject() |
a.merge(b) |
a.extend(b.shape) |
z.nativeEnum(E) |
z.enum(E) |
If schemas have to ship to the browser for client-side validation, zod/mini provides the same validators through a tree-shakable functional API. Server-only schemas don’t add to the client bundle, which is one more reason to keep validation on the server side of the 'use client' boundary.
Checklist
- Every Server Action and Route Handler parses its input with a schema and checks authorisation itself.
- Actions return a discriminated result union. Throwing is for bugs.
- State that has “modes” is a union with a literal discriminator, not a set of booleans.
- Every
switchover a union ends inassertNever. - Third-party responses are parsed at the fetch, with
looseObjectand per-item parsing where the source can change. process.envis parsed once, in a server-only module, at boot.- No deprecated Zod 3 calls remain (search for
.flatten(,message:andz.string().email).
Questions people actually ask
- If I use TypeScript, why do I need Zod as well?
- TypeScript checks the code you write. Zod checks the data you receive. Types are erased at compile time, so a
FormDatafield or an API response is whatever the sender sent. Zod connects the two: the schema validates at runtime and supplies the static type. - Should the same schema run on the client and the server?
- It can, for instant field feedback, but the server parse is the one that counts, because anyone can call the action directly. Share the schema module if the client bundle size is acceptable (consider
zod/mini). Never skip the server-side parse.
Tightening types across a large Next.js codebase?
I audit Server Actions, Route Handlers and integration code for unvalidated boundaries and state that can’t be reached, then help your team adopt schema-first contracts without a big-bang rewrite.