Next.js10 min read
Validation with Zod
A Zod schema describes the exact shape of a piece of data. Write it once and use it everywhere: form, Server Action, TypeScript types and API responses.
Try it first
This form is validated by a real Zod 4 schema wired to React Hook Form. Error messages are keys translated from JSON. On the right, the raw safeParse result updates as you type.
signupSchema.safeParse(values)error
{
"success": false,
"fieldErrors": {
"fullName": [
"nameMin"
],
"email": [
"emailInvalid"
],
"password": [
"passwordMin",
"passwordDigit"
],
"terms": [
"termsRequired"
]
}
}flattenError groups errors by field: that's the format a Server Action returns.
One schema, four uses
The schema lives in features/[name]/schemas.ts. Hover each use.
features/invoices/schemas.ts
export const invoiceSchema = z.object({
customerId: z.number().int(),
dueAt: z.iso.date(),
lines: z.array(lineSchema).min(1),
});Writing the schema
Use Zod 4's error parameter with a translation key, not a sentence. Types are inferred with z.input and z.output.
import { z } from 'zod';
// Messages are translation keys (messages/<locale>/validation.json), not sentences
export const invoiceLineSchema = z.object({
label: z.string().trim().min(1, { error: 'required' }).max(120, { error: 'tooLong' }),
quantity: z.number().int().min(1, { error: 'quantityMin' }),
unitPrice: z.number().int().nonnegative(), // cents, like the Laravel API
});
export const createInvoiceSchema = z.object({
customerId: z.number({ error: 'required' }).int().positive(),
dueAt: z.iso.date({ error: 'invalidDate' }).refine((d) => new Date(d) > new Date(), { error: 'dueInPast' }),
lines: z.array(invoiceLineSchema).min(1, { error: 'linesMin' }),
});
export type CreateInvoiceInput = z.input<typeof createInvoiceSchema>;
export type CreateInvoice = z.output<typeof createInvoiceSchema>;
// Parse what the API sends back too: a renamed field fails loudly, not silently
export const invoiceSchema = z.object({
id: z.number(),
number: z.string(),
status: z.enum(['draft', 'sent', 'paid']),
total: z.number(),
due_at: z.iso.date(),
});Form and Server Action
The form validates with zodResolver, then sends data to the Server Action through useActionState. The action validates again with the same schema and returns per-field errors, including those from a Laravel 422.
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { startTransition, useActionState } from 'react';
import { useForm } from 'react-hook-form';
import { createInvoice } from '../actions';
import { createInvoiceSchema, type CreateInvoice, type CreateInvoiceInput } from '../schemas';
export function InvoiceForm() {
const t = useTranslations('invoices.form');
const tv = useTranslations('validation');
const [state, formAction, pending] = useActionState(createInvoice, { status: 'idle' });
const form = useForm<CreateInvoiceInput, unknown, CreateInvoice>({
resolver: zodResolver(createInvoiceSchema),
mode: 'onTouched',
defaultValues: { lines: [{ label: '', quantity: 1, unitPrice: 0 }] },
errors: state.status === 'invalid' ? state.errors : undefined, // server errors shown in the same place
});
// Client validation first, then the Server Action validates again
const onSubmit = form.handleSubmit((data) => startTransition(() => formAction(data)));
return (
<form onSubmit={onSubmit} noValidate>
<label htmlFor="dueAt">{t('dueAt')}</label>
<input id="dueAt" type="date" aria-invalid={!!form.formState.errors.dueAt} {...form.register('dueAt')} />
{form.formState.errors.dueAt?.message && (
<p role="alert">{tv(form.formState.errors.dueAt.message)}</p>
)}
{/* ...lines with useFieldArray */}
<button type="submit" disabled={pending}>{pending ? t('saving') : t('save')}</button>
</form>
);
}'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { apiFetch, ApiValidationError } from '@/lib/api-client';
import { createInvoiceSchema } from './schemas';
export type ActionState =
| { status: 'idle' }
| { status: 'invalid'; errors: Record<string, { type: string; message: string }> };
export async function createInvoice(_prev: ActionState, input: unknown): Promise<ActionState> {
// Never trust the client: validate again on the server
const parsed = createInvoiceSchema.safeParse(input);
if (!parsed.success) {
return { status: 'invalid', errors: toFieldErrors(z.flattenError(parsed.error).fieldErrors) };
}
try {
await apiFetch('/v1/invoices', { method: 'POST', body: toApiPayload(parsed.data) });
} catch (error) {
if (error instanceof ApiValidationError) return { status: 'invalid', errors: error.fieldErrors }; // Laravel 422
throw error; // handled by error.tsx
}
updateTag('invoices'); // the list shows the new invoice immediately
redirect('/invoices');
}Avoid
// Validation only in the browser
<input required minLength={2} />
// Server Action trusts the data
export async function createInvoice(formData: FormData) {
await api.post('/invoices', Object.fromEntries(formData));
}Do
// One schema, used on both sides
const form = useForm({ resolver: zodResolver(createInvoiceSchema) });
export async function createInvoice(_: ActionState, input: unknown) {
const parsed = createInvoiceSchema.safeParse(input);
if (!parsed.success) return invalid(parsed.error);
// ...
}Translating errors
The keys used in schemas live in validation.json. The component renders tv(error.message).
{
"required": "Ce champ est obligatoire.",
"tooLong": "Ce texte est trop long.",
"invalidDate": "Saisis une date valide.",
"dueInPast": "L'échéance doit être dans le futur.",
"linesMin": "Ajoute au moins une ligne.",
"quantityMin": "La quantité doit être au moins 1."
}{
"required": "This field is required.",
"tooLong": "This text is too long.",
"invalidDate": "Enter a valid date.",
"dueInPast": "The due date must be in the future.",
"linesMin": "Add at least one line.",
"quantityMin": "Quantity must be at least 1."
}Validating environment variables
A missing variable should break startup, not a page in production. Validate process.env once, in a server file.
import 'server-only';
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
API_URL: z.url(),
SESSION_SECRET: z.string().min(32),
});
// Crashes at startup with a clear message instead of failing on the first request
export const env = envSchema.parse(process.env);What changed in Zod 4
Zod 4 is faster and lighter. If you're reading a Zod 3 tutorial, here are the equivalents.
// Zod 3 // Zod 4
z.string().email() z.email()
z.string().uuid() z.uuid()
z.string().min(2, { message: 'Too short' }) z.string().min(2, { error: 'tooShort' })
error.flatten() z.flattenError(error)
error.format() z.treeifyError(error)
z.prettifyError(error) // readable logs
z.toJSONSchema(schema) // OpenAPI, AI tools