Les formulaires sont la source de bugs la plus courante dans les applications web en production. Un e-mail invalide passe entre les mailles, un champ obligatoire est transmis vide, ou une erreur serveur laisse l'utilisateur face à une page blanche sans aucun retour. React Hook Form v8 combiné à Zod v4 et aux Server Actions Next.js 15 comble ces trois lacunes : vous obtenez une validation pilotée par schéma qui s'exécute à la fois côté client et côté serveur, une amélioration progressive pour que les formulaires fonctionnent même avant l'hydratation JavaScript, et une sûreté de typage qui s'écoule du schéma jusqu'à l'appel en base de données.
Ce tutoriel vous guide étape par étape dans la construction d'un formulaire de contact complet et prêt pour la production — le genre qu'une entreprise logicielle tunisienne ou une startup SaaS MENA enverrait réellement en production. À la fin, vous disposerez de :
- Un schéma Zod v4 partagé entre client et serveur
- React Hook Form v8 gérant l'état des champs sans composants contrôlés
- Une Server Action Next.js 15 qui valide et persiste la soumission
- Un retour UI optimiste avec le hook
useActionState - La gestion des pièces jointes avec validation de taille et de type MIME
- Des messages d'erreur accessibles liés à leurs champs
Pourquoi cette stack en 2026 ?
Trois choses se sont produites rapidement et font de cette combinaison le choix par défaut pour les formulaires en production :
Zod v4 (sorti début 2026) a réécrit ses composants internes avec une amélioration de vitesse de parsing de 3 à 14 fois et a ajouté un support natif de z.file() pour les objets File du navigateur. La surface d'API a à peine changé, donc la migration depuis v3 se fait en une ligne.
React Hook Form v8 a abandonné sa dépendance sur React 18 et adopte pleinement les patterns React 19. L'intégration useFormState / useActionState est désormais officielle, ce qui signifie qu'on peut appeler une Server Action depuis handleSubmit de RHF et récupérer directement des erreurs structurées sans câblage personnalisé.
Les Server Actions Next.js 15 sont stables et supportent FormData, les valeurs de retour structurées et l'amélioration progressive nativement.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et pnpm installés
- Une connaissance de base de React et du Next.js App Router
- À l'aise avec TypeScript (nous utilisons le mode strict tout au long)
- Un éditeur de code (VS Code avec l'extension Zod recommandé)
Ce que vous allez construire
Un formulaire "Demande de devis" avec les champs suivants :
| Champ | Validation |
|---|---|
| Nom complet | Requis, 2–100 caractères |
| E-mail valide, en minuscules | |
| Entreprise | Optionnel, 100 caractères max |
| Service souhaité | Enum — l'un de quatre choix |
| Fourchette budgétaire | Enum — l'une de cinq fourchettes |
| Message | Requis, 20–2000 caractères |
| Pièce jointe | PDF optionnel, 5 Mo max |
Le formulaire utilise la validation côté client pour un retour instantané et une revalidation côté serveur à l'intérieur de la Server Action avant que les données n'atteignent la couche de stockage.
Étape 1 : Créer le projet
pnpm create next-app@latest proposal-form \
--typescript \
--tailwind \
--app \
--eslint \
--src-dir \
--import-alias "@/*"
cd proposal-formInstaller les bibliothèques de formulaires et de validation :
pnpm add react-hook-form zod @hookform/resolvers@hookform/resolvers est le pont officiel entre React Hook Form et les bibliothèques de validation par schéma. Il enveloppe Zod (entre autres) pour que RHF puisse appeler schema.parse() aux bons moments.
Étape 2 : Définir le schéma Zod
Créez src/lib/schemas/proposal.ts. Conserver le schéma dans lib/ (pas dans un composant ou un fichier Server Action) permet au client et au serveur de l'importer sans problèmes de bundling.
// src/lib/schemas/proposal.ts
import { z } from "zod";
export const SERVICE_OPTIONS = [
"web-development",
"mobile-app",
"ai-integration",
"consulting",
] as const;
export const BUDGET_OPTIONS = [
"under-5k",
"5k-20k",
"20k-50k",
"50k-100k",
"above-100k",
] as const;
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 Mo
const ACCEPTED_TYPES = ["application/pdf"] as const;
export const proposalSchema = z.object({
fullName: z
.string()
.min(2, "Le nom doit comporter au moins 2 caractères")
.max(100, "Le nom doit contenir moins de 100 caractères")
.trim(),
email: z
.string()
.email("Veuillez entrer une adresse e-mail valide")
.toLowerCase(),
company: z
.string()
.max(100, "Le nom de l'entreprise doit contenir moins de 100 caractères")
.trim()
.optional()
.or(z.literal("")),
service: z.enum(SERVICE_OPTIONS, {
errorMap: () => ({ message: "Veuillez sélectionner un service" }),
}),
budget: z.enum(BUDGET_OPTIONS, {
errorMap: () => ({ message: "Veuillez sélectionner une fourchette budgétaire" }),
}),
message: z
.string()
.min(20, "Le message doit comporter au moins 20 caractères")
.max(2000, "Le message doit contenir moins de 2000 caractères")
.trim(),
attachment: z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "Le fichier doit faire moins de 5 Mo")
.refine(
(f) => ACCEPTED_TYPES.includes(f.type as (typeof ACCEPTED_TYPES)[number]),
"Seuls les fichiers PDF sont acceptés"
)
.optional(),
});
export type ProposalFormData = z.infer<typeof proposalSchema>;Quelques points à noter :
.toLowerCase()sur le champ e-mail transforme la valeur à la validation, doncAlice@Example.COMdevientalice@example.comavant d'atteindre votre base de données..trim()supprime les espaces blancs en début et fin de chaîne de tous les champs texte.z.instanceof(File)exploite la connaissance native des fichiers dans Zod v4. Côté serveur, oùFilen'est pas une variable globale, on utilisez.custom()à la place — nous traitons cela à l'étape 4.- Le pattern
.optional().or(z.literal(""))surcompanygère le cas où un champ HTML soumet une chaîne vide plutôt queundefined.
Étape 3 : Construire la Server Action
Créez src/app/actions/proposal.ts. Les Server Actions doivent se trouver dans des fichiers marqués "use server" en haut.
// src/app/actions/proposal.ts
"use server";
import { z } from "zod";
import { proposalSchema } from "@/lib/schemas/proposal";
// Schéma côté serveur : File devient Blob car Node ne fournit pas File globalement
const serverSchema = proposalSchema.extend({
attachment: z
.instanceof(Blob)
.refine((b) => b.size <= 5 * 1024 * 1024, "Le fichier doit faire moins de 5 Mo")
.refine(
(b) => b.type === "application/pdf",
"Seuls les fichiers PDF sont acceptés"
)
.optional(),
});
export type ProposalActionState = {
success: boolean;
message: string;
errors?: Partial<Record<keyof z.infer<typeof proposalSchema>, string[]>>;
};
export async function submitProposal(
_prev: ProposalActionState,
formData: FormData
): Promise<ProposalActionState> {
const raw = {
fullName: formData.get("fullName"),
email: formData.get("email"),
company: formData.get("company"),
service: formData.get("service"),
budget: formData.get("budget"),
message: formData.get("message"),
attachment: formData.get("attachment") as Blob | null,
};
if (!raw.attachment || (raw.attachment as Blob).size === 0) {
delete raw.attachment;
}
const parsed = serverSchema.safeParse(raw);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
return {
success: false,
message: "Veuillez corriger les erreurs ci-dessous et réessayer.",
errors: fieldErrors,
};
}
const { fullName, email, company, service, budget, message } = parsed.data;
// TODO : enregistrer en base de données, envoyer un e-mail de notification, etc.
console.log("Demande reçue :", { fullName, email, company, service, budget, message });
await new Promise((r) => setTimeout(r, 400));
return {
success: true,
message: `Merci ${fullName} ! Nous vous répondrons à ${email} dans les 24 heures.`,
};
}La signature de fonction (prev, formData) est requise par useActionState — le hook passe toujours l'état précédent comme premier argument et le nouveau FormData comme second.
Étape 4 : Créer le composant de formulaire
Créez src/components/ProposalForm.tsx. Comme ce composant utilise des hooks navigateur, il doit être un Client Component.
// src/components/ProposalForm.tsx
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
proposalSchema,
type ProposalFormData,
SERVICE_OPTIONS,
BUDGET_OPTIONS,
} from "@/lib/schemas/proposal";
import { submitProposal, type ProposalActionState } from "@/app/actions/proposal";
const INITIAL_STATE: ProposalActionState = {
success: false,
message: "",
};
const SERVICE_LABELS: Record<(typeof SERVICE_OPTIONS)[number], string> = {
"web-development": "Développement web",
"mobile-app": "Application mobile",
"ai-integration": "Intégration IA",
"consulting": "Conseil",
};
const BUDGET_LABELS: Record<(typeof BUDGET_OPTIONS)[number], string> = {
"under-5k": "Moins de 5 000 $",
"5k-20k": "5 000 – 20 000 $",
"20k-50k": "20 000 – 50 000 $",
"50k-100k": "50 000 – 100 000 $",
"above-100k": "Plus de 100 000 $",
};
export function ProposalForm() {
const [state, action, isPending] = useActionState(submitProposal, INITIAL_STATE);
const formRef = useRef<HTMLFormElement>(null);
const {
register,
handleSubmit,
formState: { errors },
reset,
setError,
} = useForm<ProposalFormData>({
resolver: zodResolver(proposalSchema),
defaultValues: {
fullName: "",
email: "",
company: "",
message: "",
},
});
// Synchroniser les erreurs serveur avec l'état d'erreurs de RHF
useEffect(() => {
if (state.errors) {
for (const [field, messages] of Object.entries(state.errors)) {
if (messages && messages.length > 0) {
setError(field as keyof ProposalFormData, {
type: "server",
message: messages[0],
});
}
}
}
if (state.success) {
reset();
formRef.current?.reset();
}
}, [state, setError, reset]);
const onSubmit = handleSubmit(() => {
formRef.current?.requestSubmit();
});
return (
<form ref={formRef} action={action} onSubmit={onSubmit} noValidate>
{state.message && (
<div
role="alert"
className={`mb-6 rounded-lg p-4 text-sm ${
state.success
? "bg-green-50 text-green-800 border border-green-200"
: "bg-red-50 text-red-800 border border-red-200"
}`}
>
{state.message}
</div>
)}
{/* Nom complet */}
<div className="mb-4">
<label htmlFor="fullName" className="block text-sm font-medium mb-1">
Nom complet <span aria-hidden="true">*</span>
</label>
<input
id="fullName"
type="text"
autoComplete="name"
{...register("fullName")}
aria-describedby={errors.fullName ? "fullName-error" : undefined}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.fullName && (
<p id="fullName-error" className="mt-1 text-xs text-red-600">
{errors.fullName.message}
</p>
)}
</div>
{/* E-mail */}
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium mb-1">
E-mail <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
autoComplete="email"
{...register("email")}
aria-describedby={errors.email ? "email-error" : undefined}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p id="email-error" className="mt-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Entreprise (optionnel) */}
<div className="mb-4">
<label htmlFor="company" className="block text-sm font-medium mb-1">
Entreprise <span className="text-gray-400 text-xs">(optionnel)</span>
</label>
<input
id="company"
type="text"
autoComplete="organization"
{...register("company")}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.company && (
<p className="mt-1 text-xs text-red-600">{errors.company.message}</p>
)}
</div>
{/* Service */}
<div className="mb-4">
<label htmlFor="service" className="block text-sm font-medium mb-1">
Service souhaité <span aria-hidden="true">*</span>
</label>
<select
id="service"
{...register("service")}
aria-describedby={errors.service ? "service-error" : undefined}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sélectionner un service…</option>
{SERVICE_OPTIONS.map((v) => (
<option key={v} value={v}>
{SERVICE_LABELS[v]}
</option>
))}
</select>
{errors.service && (
<p id="service-error" className="mt-1 text-xs text-red-600">
{errors.service.message}
</p>
)}
</div>
{/* Budget */}
<div className="mb-4">
<label htmlFor="budget" className="block text-sm font-medium mb-1">
Fourchette budgétaire <span aria-hidden="true">*</span>
</label>
<select
id="budget"
{...register("budget")}
aria-describedby={errors.budget ? "budget-error" : undefined}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sélectionner une fourchette…</option>
{BUDGET_OPTIONS.map((v) => (
<option key={v} value={v}>
{BUDGET_LABELS[v]}
</option>
))}
</select>
{errors.budget && (
<p id="budget-error" className="mt-1 text-xs text-red-600">
{errors.budget.message}
</p>
)}
</div>
{/* Message */}
<div className="mb-4">
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message <span aria-hidden="true">*</span>
</label>
<textarea
id="message"
rows={5}
{...register("message")}
aria-describedby={errors.message ? "message-error" : undefined}
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
{errors.message && (
<p id="message-error" className="mt-1 text-xs text-red-600">
{errors.message.message}
</p>
)}
</div>
{/* Pièce jointe */}
<div className="mb-6">
<label htmlFor="attachment" className="block text-sm font-medium mb-1">
Pièce jointe <span className="text-gray-400 text-xs">(PDF, 5 Mo max)</span>
</label>
<input
id="attachment"
type="file"
accept="application/pdf"
{...register("attachment")}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700"
/>
{errors.attachment && (
<p className="mt-1 text-xs text-red-600">{errors.attachment.message}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Envoi en cours…" : "Envoyer la demande de devis"}
</button>
</form>
);
}Étape 5 : Intégrer dans une page
// src/app/contact/page.tsx
import { ProposalForm } from "@/components/ProposalForm";
export const metadata = {
title: "Demander un devis",
description: "Parlez-nous de votre projet et nous vous répondrons dans les 24 heures.",
};
export default function ContactPage() {
return (
<main className="mx-auto max-w-lg px-4 py-16">
<h1 className="text-2xl font-bold mb-2">Demander un devis</h1>
<p className="text-gray-500 text-sm mb-8">
Remplissez les détails ci-dessous. Nous vous répondrons dans un jour ouvrable.
</p>
<ProposalForm />
</main>
);
}Comment les deux couches de validation fonctionnent ensemble
Comprendre l'architecture à deux couches évite les bugs :
Couche 1 — Client (React Hook Form + résolveur Zod) : Quand l'utilisateur clique sur Envoyer, handleSubmit appelle zodResolver(proposalSchema) avant que la Server Action ne se déclenche. Les erreurs peuplent immédiatement la carte d'erreurs de RHF, sans aucun aller-retour réseau. L'utilisateur voit les messages d'erreur en ligne sous les champs concernés.
Couche 2 — Serveur (Server Action + serverSchema) : Même si quelqu'un désactive JavaScript ou envoie une requête fetch brute pour contourner le client, la Server Action revalide avec serverSchema.safeParse(). Les erreurs reviennent dans l'objet structuré state.errors, et le useEffect du composant les synchronise dans l'état d'erreurs de RHF pour qu'elles s'affichent exactement de la même façon.
La seule différence entre les deux schémas est le champ attachment : côté client z.instanceof(File) fonctionne car File est une variable globale du navigateur ; côté serveur on le remplace par z.instanceof(Blob) que Node 20+ fournit nativement.
Étape 6 : Ajouter le retour optimiste
import { useOptimistic } from "react";
// Dans le composant :
const [optimisticSent, setOptimisticSent] = useOptimistic(false);
const onSubmit = handleSubmit(() => {
setOptimisticSent(true);
formRef.current?.requestSubmit();
});
// Dans le JSX :
{optimisticSent && !state.success && (
<p className="text-blue-600 text-sm mb-4 animate-pulse">
Envoi de votre demande en cours…
</p>
)}useOptimistic de React 19 passe à true dès l'instant où l'utilisateur clique sur Envoyer et se réinitialise automatiquement une fois la Server Action résolue. Cela maintient une interface réactive même sur les connexions lentes.
Étape 7 : Règles de validation personnalisées
Les méthodes .refine() et .superRefine() de Zod permettent d'ajouter des règles métier qui vont au-delà des vérifications de format.
// Exemple : nom d'entreprise requis pour les budgets importants
export const proposalSchema = z
.object({
// ... champs comme précédemment
})
.refine(
(data) => {
if (data.budget === "above-100k" && !data.company) {
return false;
}
return true;
},
{
message: "Le nom d'entreprise est requis pour les budgets importants",
path: ["company"],
}
);La validation inter-champs comme celle-ci est là où Zod excelle. L'option path indique à React Hook Form quel champ mettre en évidence, donc l'erreur apparaît sous "Entreprise" même si la règle compare deux champs.
Tester votre implémentation
Démarrez le serveur de développement :
pnpm devPuis naviguez vers http://localhost:3000/contact et testez ces scénarios :
Chemin nominal : Remplissez tous les champs requis, joignez un PDF valide de moins de 5 Mo, envoyez. Vous devriez voir la bannière de succès.
Validation côté client : Soumettez avec un nom vide. L'erreur doit apparaître instantanément sans indicateur de chargement et sans requête réseau.
Contournement côté serveur : Ouvrez les DevTools, allez dans Network, faites un clic droit sur la soumission du formulaire et désactivez JavaScript. Soumettez via l'action de formulaire native. La page doit se recharger avec les mêmes erreurs par champ rendues côté serveur.
Rejet de fichier : Joignez une image ou un fichier de plus de 5 Mo. L'erreur doit apparaître côté client avant que le formulaire ne soit soumis.
Résolution des problèmes courants
setError is not a function ou les erreurs serveur n'apparaissent pas : Vérifiez que votre tableau de dépendances dans useEffect inclut [state, setError, reset]. L'absence de state signifie que l'effet ne se réexécute jamais après la résolution de l'action.
Le champ de fichier ne se réinitialise pas après le succès : Les champs de fichier HTML sont non contrôlés par nature. L'appel formRef.current?.reset() dans useEffect réinitialise l'élément de formulaire natif, ce qui vide le sélecteur de fichiers. N'essayez pas de réinitialiser le champ de fichier via RHF seul.
Erreur TypeScript sur z.instanceof(File) dans le module serveur : Si votre Server Action importe accidentellement proposalSchema (qui utilise z.instanceof(File)), Node lancera une erreur car File n'est pas défini côté serveur. Gardez serverSchema avec z.instanceof(Blob) dans le fichier action et importez proposalSchema uniquement dans les Client Components.
safeParse retourne success mais parsed.data est manquant pour la transformation email : Zod v4 applique les transformations lors de parse()/safeParse(). Vérifiez que vous lisez parsed.data.email (la valeur transformée) plutôt que raw.email (la chaîne originale).
Prochaines étapes
Avec cette base en place, envisagez ces améliorations :
- Persister en base de données avec Drizzle ORM ou Prisma en utilisant l'objet typé
parsed.data— les deux acceptent directement le type inféré de Zod - Envoyer des notifications par e-mail avec
@react-email/componentsde Resend lors d'une soumission réussie - Ajouter un CAPTCHA avec Cloudflare Turnstile pour prévenir le spam sans impacter les vrais utilisateurs
- Formulaire multi-étapes — répartissez les champs sur plusieurs pages, en stockant l'état partiel dans
sessionStorage - Limitation de débit — enveloppez la Server Action avec Upstash Redis ou Arcjet pour prévenir les abus
Conclusion
React Hook Form v8 et Zod v4 ne sont pas des technologies nouvelles — elles sont matures depuis des années — mais leur intégration avec les Server Actions Next.js 15 élimine enfin l'écart gênant entre la validation côté client et côté serveur. Le schéma défini dans lib/schemas/proposal.ts est la source de vérité unique : il pilote les types TypeScript, les messages d'erreur côté client, la revalidation côté serveur et (avec un mince adaptateur) la forme d'insertion en base de données.
Le pattern évolue proprement. Un formulaire de paiement, un flux d'onboarding multi-tenant, ou un tableau de bord d'administration avec validation poussée suivent tous la même structure : schéma dans lib/, action dans app/actions/, composant dans components/. Gardez ces trois pièces séparées et vous ne vous retrouverez jamais à traquer un bug de validation qui ne se reproduit qu'en production.