Les formulaires sont le point de friction numéro un dans la plupart des applications web. État local, validation, messages d'erreur, accessibilité, soumission asynchrone, intégration avec les Server Actions de Next.js… On peut très vite se retrouver avec des composants de 300 lignes et une UX médiocre. La combinaison React Hook Form + Zod règle 90 % de ces problèmes en imposant un modèle simple : un schéma de validation unique, des types TypeScript inférés, et un rendu performant qui ne déclenche pas de re-render inutile.
Dans cet article, je détaille comment je structure mes formulaires Next.js en production, du schéma Zod jusqu'à l'envoi côté Server Action, avec des exemples concrets et les pièges que j'ai rencontrés.
Pourquoi React Hook Form plutôt qu'un state local ?
À première vue, gérer un formulaire avec useState paraît trivial. En pratique, dès qu'on dépasse trois champs, plusieurs problèmes apparaissent : chaque frappe re-render tout le composant, la logique de validation se duplique, les erreurs sont éparpillées, et l'accessibilité (focus, aria-invalid, messages liés) devient pénible à maintenir.
React Hook Form (RHF) résout ce trio gagnant : il utilise des refs non contrôlées par défaut, donc les frappes n'entraînent pas de re-render du formulaire entier, seulement du champ concerné si on s'y abonne explicitement. Sur des formulaires de 10–20 champs, c'est la différence entre 1500 re-renders et 50.
Couplé à Zod pour la validation, on obtient un schéma unique qui sert à la fois de source de vérité du shape, de validateur runtime, et de générateur de types TypeScript via z.infer. Plus besoin de maintenir un type FormValues à part : il découle automatiquement du schéma.
Installation et setup
Pour un projet Next.js (App Router) en TypeScript :
npm install react-hook-form zod @hookform/resolvers
Le package @hookform/resolvers fournit l'adaptateur Zod qui branche le schéma sur RHF. C'est le seul "pont" nécessaire entre les deux librairies.
Définir un schéma Zod réutilisable
Je place toujours mes schémas Zod dans un dossier src/lib/schemas/ dédié, séparés des composants. Ça permet de les partager entre le formulaire client et la Server Action côté serveur — et c'est précisément l'argument fort de cette stack : une seule source de validation, exécutée des deux côtés.
// src/lib/schemas/contact.schema.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z
.string()
.min(2, "Le nom doit faire au moins 2 caractères")
.max(80, "Le nom est trop long"),
email: z.string().email("Email invalide"),
subject: z.enum(["devis", "question", "autre"], {
errorMap: () => ({ message: "Sélectionnez un sujet" }),
}),
message: z
.string()
.min(20, "Détaillez votre demande (20 caractères min)")
.max(2000, "Message trop long"),
consent: z.literal(true, {
errorMap: () => ({ message: "Vous devez accepter la politique de confidentialité" }),
}),
});
export type ContactFormValues = z.infer<typeof contactSchema>;
Le z.literal(true) sur la case de consentement RGPD est un petit pattern que j'utilise systématiquement : il force la valeur exacte true, ce qui rend impossible la soumission tant que la case n'est pas cochée. Bien plus propre qu'un boolean().refine(v => v === true).
Construire le formulaire côté client
Côté composant, le hook useForm reçoit le resolver Zod et infère immédiatement les types. Pas besoin de typer manuellement chaque champ.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormValues } from "@/lib/schemas/contact.schema";
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
mode: "onBlur",
defaultValues: {
name: "",
email: "",
subject: "devis",
message: "",
consent: false as unknown as true,
},
});
const onSubmit = handleSubmit(async (data) => {
// données déjà validées et typées comme ContactFormValues
await sendContact(data);
reset();
});
return (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div>
<label htmlFor="name">Nom</label>
<input
id="name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && <p id="name-error" role="alert">{errors.name.message}</p>}
</div>
{/* … autres champs sur le même modèle */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Envoi…" : "Envoyer"}
</button>
</form>
);
}
Trois points méritent qu'on s'y arrête.
D'abord, mode: "onBlur". Par défaut, RHF valide à la soumission. C'est utilisable mais pas idéal côté UX : l'utilisateur n'a aucun feedback avant de cliquer. À l'inverse, onChange est trop agressif (erreur affichée à la première lettre tapée). onBlur est le bon compromis : on valide quand le champ perd le focus, puis on revalide à chaque frappe une fois qu'une erreur a été affichée.
Ensuite, noValidate sur la balise <form>. Sans ça, le navigateur applique sa propre validation HTML5 par-dessus celle de Zod, ce qui crée des messages d'erreur incohérents et un comportement imprévisible. Avec RHF + Zod, on prend le contrôle total — donc on désactive HTML5.
Enfin, les attributs ARIA. aria-invalid et aria-describedby permettent aux lecteurs d'écran d'annoncer correctement l'erreur. Le role="alert" sur le message d'erreur déclenche l'annonce vocale automatique dès qu'il apparaît. C'est trois lignes de code qui font passer un formulaire de "potentiellement inutilisable au clavier" à "WCAG 2.1 AA-friendly".
Brancher une Server Action Next.js
L'intérêt de partager le schéma Zod avec le serveur prend tout son sens ici. La Server Action revalide les données reçues — parce qu'on ne fait jamais confiance au client, même quand RHF a déjà validé.
// src/app/contact/actions.ts
"use server";
import { contactSchema } from "@/lib/schemas/contact.schema";
export async function sendContact(input: unknown) {
const result = contactSchema.safeParse(input);
if (!result.success) {
return { ok: false, errors: result.error.flatten().fieldErrors };
}
// result.data est typé ContactFormValues, prêt à insérer en BDD ou à envoyer par email
await saveLead(result.data);
return { ok: true };
}
Le safeParse retourne un objet discriminé (success: true | false) plutôt que de throw, ce qui se prête bien à un retour structuré vers le client. On peut récupérer ces erreurs côté formulaire et les injecter dans RHF avec setError pour réafficher des erreurs venues du serveur (email déjà utilisé, validation métier, etc.).
Gérer les erreurs serveur dans React Hook Form
Le pattern que j'utilise pour faire remonter les erreurs serveur dans le formulaire :
const onSubmit = handleSubmit(async (data) => {
const res = await sendContact(data);
if (!res.ok && res.errors) {
Object.entries(res.errors).forEach(([field, messages]) => {
setError(field as keyof ContactFormValues, {
type: "server",
message: messages?.[0] ?? "Erreur",
});
});
return;
}
reset();
});
C'est une boucle de 6 lignes qui transforme une réponse Zod côté serveur en erreurs visibles dans les bons champs côté client, sans dupliquer la moindre logique.
Champs dynamiques et useFieldArray
Pour les listes (ajouter plusieurs lignes de devis, plusieurs participants, etc.), useFieldArray gère l'ajout/suppression d'éléments avec une API claire :
const { fields, append, remove } = useFieldArray({ control, name: "items" });
Chaque élément reçoit une id stable utilisable comme key React. Côté Zod, on déclare le tableau avec z.array(itemSchema).min(1) et tout reste typé jusqu'au bout.
Pièges fréquents à éviter
Quelques erreurs que je vois revenir régulièrement en code review.
Mettre le schéma Zod dans le composant. Ça empêche de le partager avec le serveur et oblige à le redéfinir. Le schéma vit dans lib/schemas/, point.
Oublier defaultValues. Sans valeurs par défaut, RHF passe les champs en "uncontrolled" puis "controlled" au premier rendu, ce qui produit le warning React classique. Toujours initialiser tous les champs, même à des chaînes vides.
Coller mode: "all" partout. Ça déclenche la validation à chaque frappe ET à chaque blur ET à la soumission. UX désagréable et coût de calcul inutile sur des formulaires complexes. onBlur suffit dans 95 % des cas.
Ne pas désactiver le bouton pendant isSubmitting. Sans ça, un utilisateur qui double-clique soumet deux fois, ce qui peut créer des doublons en BDD. Le disabled={isSubmitting} sur le bouton est non négociable.
Pour aller plus loin
Si vous voulez creuser, j'ai déjà publié un article dédié à Zod pour la validation TypeScript dans Next.js et NestJS qui complète celui-ci côté serveur, ainsi qu'un guide sur les Server Actions Next.js 15 qui explique le contexte d'exécution côté serveur dans lequel s'inscrit cette stack.
Conclusion
React Hook Form + Zod, c'est la combinaison la plus solide que je connaisse pour les formulaires Next.js modernes. Un schéma unique qui produit à la fois les types, la validation client et la validation serveur. Des re-renders minimaux. Une accessibilité gérable. Et une intégration naturelle avec les Server Actions. Une fois la pattern en place, ajouter un nouveau formulaire à un projet prend une dizaine de minutes — pas une après-midi.
Vous avez un projet Next.js avec des formulaires complexes (multi-étapes, paiement, onboarding) à mettre en place ? N'hésitez pas à me contacter pour en discuter, je serai ravi de vous accompagner.