Skip to content
Lantorian

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.

Errors show when you leave a field, then clear live as you fix them. Nothing is sent.

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),
});
When the schema changes, the form, the action and the types follow automatically.

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.

src/features/invoices/schemas.ts
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.

src/features/invoices/components/invoice-form.tsx
'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>
  );
}

Avoid

actions.ts
// 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

actions.ts
// 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).

messages/fr/validation.json
{
  "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."
}

Validating environment variables

A missing variable should break startup, not a page in production. Validate process.env once, in a server file.

src/lib/env.ts
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
// 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