Aller au contenu
Lantorian

Next.js10 min de lecture

Validation avec Zod

Un schéma Zod décrit la forme exacte d'une donnée. On l'écrit une fois et il sert partout : formulaire, Server Action, types TypeScript et réponses de l'API.

Essaie d'abord

Ce formulaire est validé par un vrai schéma Zod 4 branché sur React Hook Form. Les messages d'erreur sont des clés traduites depuis le JSON. À droite, le résultat brut de safeParse se met à jour à chaque frappe.

signupSchema.safeParse(values)error

{
  "success": false,
  "fieldErrors": {
    "fullName": [
      "nameMin"
    ],
    "email": [
      "emailInvalid"
    ],
    "password": [
      "passwordMin",
      "passwordDigit"
    ],
    "terms": [
      "termsRequired"
    ]
  }
}

flattenError regroupe les erreurs par champ : c'est ce format que renvoie une Server Action.

Les erreurs apparaissent quand tu quittes un champ, puis se corrigent en direct. Rien n'est envoyé.

Un schéma, quatre usages

Le schéma vit dans features/[nom]/schemas.ts. Survole chaque usage.

features/invoices/schemas.ts

export const invoiceSchema = z.object({
  customerId: z.number().int(),
  dueAt: z.iso.date(),
  lines: z.array(lineSchema).min(1),
});
Si le schéma change, le formulaire, l'action et les types suivent automatiquement.

Écrire le schéma

On utilise le paramètre error de Zod 4 avec une clé de traduction, pas une phrase. Les types sont déduits avec z.input et 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(),
});

Formulaire et Server Action

Le formulaire valide avec zodResolver, puis envoie les données à la Server Action via useActionState. L'action revalide avec le même schéma et renvoie les erreurs par champ, y compris celles d'un 422 Laravel.

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

À éviter

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

À faire

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);
  // ...
}

Traduire les erreurs

Les clés utilisées dans les schémas vivent dans validation.json. Le composant affiche 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."
}

Valider les variables d'environnement

Une variable manquante doit casser le démarrage, pas une page en production. On valide process.env une fois, dans un fichier serveur.

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

Ce qui change avec Zod 4

Zod 4 est plus rapide et plus léger. Si tu lis un tutoriel en Zod 3, voici les équivalences.

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