Cours Next.js · Module 6: Formulaires et Server Actions
Validation de formulaires avec Zod
Dans cette leçon11
Dans la Métropole Quantique, chaque système exige une validation précise des données - des terminaux d'accès aux panneaux de contrôle des implants. Zod est une bibliothèque de validation pensée pour TypeScript, devenue le standard dans l'écosystème Next.js. Contrairement à d'autres solutions, Zod génère automatiquement les types TypeScript à partir des schémas de validation, éliminant la duplication de code.
Pourquoi Zod ?
En l'an 2150, les systèmes de la Métropole Quantique exigent une validation fiable des données. Zod se distingue des autres bibliothèques de validation (Yup, Joi) pour plusieurs raisons :
- TypeScript d'abord - les schémas Zod génèrent automatiquement les types, éliminant la duplication
- Zéro dépendance - bibliothèque légère (~10 Ko gzippés)
- Standard Next.js - officiellement recommandée dans la documentation de Next.js
- Fonctionne côté serveur et client - idéale pour les Server Actions
Installation et import
1npm install zod1import { z } from 'zod';Schémas Zod de base
Zod propose un ensemble de méthodes pour définir des types de données - comme un ADN numérique décrivant la structure de l'information dans le système :
1import { z } from 'zod';
2
3// Types primitifs
4const nameSchema = z.string();
5const ageSchema = z.number();
6const isActiveSchema = z.boolean();
7
8// Schéma d'objet - nous combinons les types en une structure
9const userSchema = z.object({
10 name: z.string(),
11 email: z.string().email(),
12 age: z.number().int().positive(),
13 isActive: z.boolean(),
14});Chaque z.string(), z.number(), z.boolean() est un bloc de construction de base d'un schéma. La méthode z.object() les combine en une structure complète - comme le schéma d'un terminal de la Métropole Quantique.
Méthodes de validation : parse et safeParse
Zod propose deux façons de valider les données :
1import { z } from 'zod';
2
3const schema = z.object({
4 name: z.string().min(2),
5 email: z.string().email(),
6});
7
8// 1. parse() - lève une exception pour des données invalides
9try {
10 const data = schema.parse({ name: "Neo", email: "neo@quantum.io" });
11 console.log("Données valides :", data);
12} catch (error) {
13 console.error("Erreur de validation :", error);
14}
15
16// 2. safeParse() - ne lève PAS d'exception, renvoie un objet résultat
17const result = schema.safeParse({ name: "", email: "bad-email" });
18
19if (result.success) {
20 console.log("Données :", result.data);
21} else {
22 console.log("Erreurs :", result.error.errors);
23 // [{ path: ["name"], message: "String must contain at least 2 character(s)" }, ...]
24}Règle : Utilise safeParse() dans les formulaires et les Server Actions (plus sûr), et parse() quand tu veux échouer rapidement sur des données invalides.
Messages d'erreur et règles de validation
Zod te permet de définir précisément les règles de validation avec des messages personnalisés - comme configurer un système de sécurité sur un terminal :
1import { z } from 'zod';
2
3const registrationSchema = z.object({
4 username: z.string()
5 .min(3, { message: "Le nom d'utilisateur doit contenir au moins 3 caractères" })
6 .max(20, { message: "Le nom d'utilisateur peut contenir au maximum 20 caractères" }),
7 email: z.string()
8 .email({ message: "Format d'adresse email invalide" }),
9 age: z.number()
10 .int({ message: "L'âge doit être un nombre entier" })
11 .min(13, { message: "Tu dois avoir au moins 13 ans" }),
12 password: z.string()
13 .min(8, { message: "Le mot de passe doit contenir au moins 8 caractères" })
14 .regex(/[A-Z]/, { message: "Le mot de passe doit contenir une majuscule" })
15 .regex(/[0-9]/, { message: "Le mot de passe doit contenir un chiffre" }),
16 acceptTerms: z.boolean()
17 .refine(val => val === true, {
18 message: "Tu dois accepter les conditions d'utilisation du système"
19 }),
20});La méthode .refine() te permet de définir des règles de validation personnalisées et non standard - utile lorsque les méthodes intégrées ne suffisent pas.
Inférence de types : z.infer
L'une des fonctionnalités les plus puissantes de Zod est la génération automatique de types TypeScript à partir des schémas. Tu n'as pas besoin de maintenir des interfaces séparées :
1import { z } from 'zod';
2
3const implantSchema = z.object({
4 id: z.string().uuid(),
5 name: z.string().min(1),
6 powerLevel: z.number().min(0).max(100),
7 isActive: z.boolean(),
8 tags: z.array(z.string()),
9});
10
11// Type généré automatiquement à partir du schéma
12type Implant = z.infer<typeof implantSchema>;
13// Équivalent manuel : { id: string; name: string; powerLevel: number; isActive: boolean; tags: string[] }
14
15// Maintenant le type et la validation sont SYNCHRONISÉS
16function processImplant(data: Implant) {
17 console.log(data.name); // TypeScript connaît le type !
18}Grâce à z.infer, tu disposes d'une source unique de vérité - le schéma définit à la fois la validation et le type TypeScript.
Intégration avec React Hook Form
Zod fonctionne très bien avec React Hook Form grâce à l'adaptateur zodResolver. C'est le modèle le plus courant dans les applications Next.js :
1import { useForm } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// 1. Définir le schéma
6const loginSchema = z.object({
7 email: z.string()
8 .email({ message: "Adresse email invalide" }),
9 password: z.string()
10 .min(8, { message: "Le mot de passe doit contenir au moins 8 caractères" }),
11});
12
13// 2. Générer le type
14type LoginForm = z.infer<typeof loginSchema>;
15
16// 3. Utiliser dans le composant
17export default function LoginPage() {
18 const {
19 register,
20 handleSubmit,
21 formState: { errors, isSubmitting }
22 } = useForm<LoginForm>({
23 resolver: zodResolver(loginSchema), // Zod comme validateur
24 defaultValues: { email: '', password: '' }
25 });
26
27 const onSubmit = async (data: LoginForm) => {
28 // data est déjà validé et typé !
29 console.log("Connexion :", data);
30 };
31
32 return (
33 <form onSubmit={handleSubmit(onSubmit)}>
34 <input {...register('email')} placeholder="Email" />
35 {errors.email && <p>{errors.email.message}</p>}
36
37 <input {...register('password')} type="password" placeholder="Mot de passe" />
38 {errors.password && <p>{errors.password.message}</p>}
39
40 <button type="submit" disabled={isSubmitting}>
41 {isSubmitting ? 'Connexion...' : 'Se connecter'}
42 </button>
43 </form>
44 );
45}La ligne clé est resolver: zodResolver(loginSchema) - elle connecte le schéma Zod au système de validation de React Hook Form.
Schémas réutilisables et composition
Dans les grandes applications, il est utile de créer des schémas réutilisables qui peuvent être combinés et étendus :
1import { z } from 'zod';
2
3// Schéma d'adresse de base - utilisé à plusieurs endroits
4const addressSchema = z.object({
5 street: z.string().min(1, "La rue est requise"),
6 city: z.string().min(1, "La ville est requise"),
7 postCode: z.string().regex(/^\d{2}-\d{3}$/, "Format : XX-XXX"),
8});
9
10// Schéma d'utilisateur - étendu avec l'adresse
11const userSchema = z.object({
12 name: z.string().min(2),
13 email: z.string().email(),
14 address: addressSchema, // Imbrication
15 shippingAddress: addressSchema.optional(), // Facultatif
16});
17
18// .merge() - fusion de deux schémas
19const baseProfile = z.object({ name: z.string(), bio: z.string() });
20const socialProfile = z.object({ twitter: z.string(), github: z.string() });
21const fullProfile = baseProfile.merge(socialProfile);
22
23// .extend() - ajout de champs à un schéma existant
24const adminUser = userSchema.extend({
25 role: z.literal('admin'),
26 permissions: z.array(z.string()),
27});
28
29type AdminUser = z.infer<typeof adminUser>;Techniques avancées : refine et superRefine
La méthode .refine() permet une validation qui ne peut pas être exprimée de façon déclarative - par ex. comparer deux champs :
1import { z } from 'zod';
2
3const passwordSchema = z.object({
4 password: z.string().min(8),
5 confirmPassword: z.string(),
6}).refine(data => data.password === data.confirmPassword, {
7 message: "Les mots de passe ne correspondent pas",
8 path: ["confirmPassword"], // Erreur assignée au champ confirmPassword
9});
10
11// superRefine - lorsque tu as besoin de plusieurs erreurs simultanément
12const advancedSchema = z.object({
13 accountType: z.enum(['personal', 'business']),
14 personalId: z.string().optional(),
15 companyId: z.string().optional(),
16}).superRefine((data, ctx) => {
17 if (data.accountType === 'personal' && !data.personalId) {
18 ctx.addIssue({
19 code: z.ZodIssueCode.custom,
20 message: "L'identifiant personnel est requis pour un compte personnel",
21 path: ['personalId'],
22 });
23 }
24 if (data.accountType === 'business' && !data.companyId) {
25 ctx.addIssue({
26 code: z.ZodIssueCode.custom,
27 message: "L'identifiant d'entreprise est requis pour un compte professionnel",
28 path: ['companyId'],
29 });
30 }
31});Alternatives : Yup et Joi
Zod est le standard dans l'écosystème Next.js, mais des alternatives existent :
- Yup - populaire avec Formik, syntaxe déclarative, mais nécessite de définir manuellement les types TypeScript
- Joi - puissante, riche en fonctionnalités, mais lourde (~40 Ko) et surtout côté serveur
Zod est recommandée pour les projets Next.js car elle combine validation et types TypeScript et fonctionne à la fois côté serveur et côté client.
Résumé
Zod est un outil essentiel dans l'arsenal du développeur Next.js :
z.string(),z.number(),z.object()- définir des schémasparse()etsafeParse()- validation des données.min(),.email(),.regex()- règles avec messages d'erreur.refine()- validation personnaliséezodResolver- intégration avec React Hook Formz.infer<typeof schema>- génération automatique de types
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Simulation de la validation avec Zod - schemas, parse, safeParse, messages d'erreur
4
5// "Zod" simplifie pour la demonstration (dans un vrai projet, tu importes z depuis le package zod)
6interface ZodIssue { path: string[]; message: string }
7interface ZodResult<T> { success: boolean; data?: T; error?: { issues: ZodIssue[] } }
8
9const z = {
10 string: () => {
11 const rules: Array<{ type: string; value?: any; msg: string }> = [];
12 const validator: any = {
13 min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n} caracteres` }); return validator; },
14 max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n} caracteres` }); return validator; },
15 email(opt?: { message?: string }) { rules.push({ type: 'email', msg: opt?.message || 'Email invalide' }); return validator; },
16 regex(re: RegExp, opt?: { message?: string }) { rules.push({ type: 'regex', value: re, msg: opt?.message || 'Format invalide' }); return validator; },
17 _validate(val: string): string | null {
18 if (typeof val !== 'string') return 'Chaine requise';
19 for (const rule of rules) {
20 if (rule.type === 'min' && val.length < rule.value) return rule.msg;
21 if (rule.type === 'max' && val.length > rule.value) return rule.msg;
22 if (rule.type === 'email' && !/\S+@\S+\.\S+/.test(val)) return rule.msg;
23 if (rule.type === 'regex' && !rule.value.test(val)) return rule.msg;
24 }
25 return null;
26 },
27 };
28 return validator;
29 },
30 coerce: {
31 number: () => {
32 const rules: Array<{ type: string; value?: any; msg: string }> = [];
33 const validator: any = {
34 min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n}` }); return validator; },
35 max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n}` }); return validator; },
36 int(opt?: { message?: string }) { rules.push({ type: 'int', msg: opt?.message || 'Nombre entier requis' }); return validator; },
37 _validate(val: string): string | null {
38 const num = Number(val);
39 if (val === '' || isNaN(num)) return 'Nombre requis';
40 for (const rule of rules) {
41 if (rule.type === 'min' && num < rule.value) return rule.msg;
42 if (rule.type === 'max' && num > rule.value) return rule.msg;
43 if (rule.type === 'int' && !Number.isInteger(num)) return rule.msg;
44 }
45 return null;
46 },
47 };
48 return validator;
49 },
50 },
51 object: (shape: Record<string, any>) => ({
52 safeParse(data: Record<string, string>): ZodResult<any> {
53 const issues: ZodIssue[] = [];
54 const parsed: any = {};
55 for (const [key, validator] of Object.entries(shape)) {
56 const err = validator._validate(data[key] || '');
57 if (err) issues.push({ path: [key], message: err });
58 else parsed[key] = data[key];
59 }
60 if (issues.length > 0) return { success: false, error: { issues } };
61 return { success: true, data: parsed };
62 },
63 parse(data: Record<string, string>) {
64 const result = this.safeParse(data);
65 if (!result.success) throw new Error('Zod validation failed: ' + result.error!.issues.map(i => i.message).join(', '));
66 return result.data;
67 },
68 }),
69};
70
71// --- SCHEMA ZOD ---
72// Dans un vrai projet, z vient du package zod
73const implantSchema = z.object({
74 name: z.string()
75 .min(2, { message: 'Le nom doit comporter au moins 2 caracteres' })
76 .max(50, { message: 'Le nom ne peut pas depasser 50 caracteres' }),
77 email: z.string()
78 .email({ message: 'Format d\'email invalide' }),
79 powerLevel: z.coerce.number()
80 .min(1, { message: 'Le niveau de puissance doit etre superieur a 0' })
81 .max(100, { message: 'Le niveau de puissance ne peut pas depasser 100' }),
82});
83
84// type Implant = z.infer<typeof implantSchema>;
85
86export default function ZodValidationDemo() {
87 const [values, setValues] = useState({ name: '', email: '', powerLevel: '' });
88 const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
89 const [result, setResult] = useState<{ type: 'success' | 'error'; data: string } | null>(null);
90 const [mode, setMode] = useState<'safeParse' | 'parse'>('safeParse');
91
92 const handleSubmit = (e: React.FormEvent) => {
93 e.preventDefault();
94 setFieldErrors({});
95 setResult(null);
96
97 if (mode === 'safeParse') {
98 const res = implantSchema.safeParse(values);
99 if (res.success) {
100 setResult({ type: 'success', data: JSON.stringify(res.data, null, 2) });
101 } else {
102 const errs: Record<string, string> = {};
103 for (const issue of res.error!.issues) {
104 errs[issue.path[0]] = issue.message;
105 }
106 setFieldErrors(errs);
107 setResult({ type: 'error', data: JSON.stringify(res.error!.issues, null, 2) });
108 }
109 } else {
110 try {
111 const data = implantSchema.parse(values);
112 setResult({ type: 'success', data: JSON.stringify(data, null, 2) });
113 } catch (err: any) {
114 setResult({ type: 'error', data: err.message });
115 }
116 }
117 };
118
119 const inputStyle: React.CSSProperties = {
120 width: '100%', padding: '10px', background: '#0d1117',
121 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
122 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
123 };
124
125 const schemaCode = `const implantSchema = z.object({
126 name: z.string().min(2).max(50),
127 email: z.string().email(),
128 powerLevel: z.coerce.number().min(1).max(100),
129});
130
131type Implant = z.infer<typeof implantSchema>;`;
132
133 return (
134 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
135 <h1 style={{ color: '#64ffda' }}>Validation avec Zod</h1>
136 <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
137 Teste le schema Zod - saisis des donnees et choisis la methode de validation.
138 </p>
139
140 <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16 }}>
141{schemaCode}
142 </pre>
143
144 <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
145 {(['safeParse', 'parse'] as const).map(m => (
146 <button key={m} onClick={() => { setMode(m); setResult(null); setFieldErrors({}); }} style={{
147 background: mode === m ? '#1565c0' : '#1a2744',
148 color: '#fff', border: `1px solid ${mode === m ? '#64ffda' : '#333'}`,
149 borderRadius: 6, padding: '6px 14px', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12
150 }}>
151 schema.{m}()
152 </button>
153 ))}
154 </div>
155
156 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
157 {(['name', 'email', 'powerLevel'] as const).map(field => (
158 <div key={field} style={{ marginBottom: 12 }}>
159 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
160 {field}
161 </label>
162 <input
163 value={values[field]}
164 onChange={e => setValues(prev => ({ ...prev, [field]: e.target.value }))}
165 placeholder={field === 'powerLevel' ? '1-100' : field === 'email' ? 'neo@quantum.io' : 'Neural Link'}
166 style={{ ...inputStyle, borderColor: fieldErrors[field] ? '#f44336' : '#333' }}
167 />
168 {fieldErrors[field] && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{fieldErrors[field]}</div>}
169 </div>
170 ))}
171 <button type="submit" style={{
172 background: '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
173 padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
174 }}>
175 schema.{mode}(formData)
176 </button>
177 </form>
178
179 {result && (
180 <div style={{
181 marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
182 background: result.type === 'success' ? '#1b5e20' : '#3e1010',
183 border: `1px solid ${result.type === 'success' ? '#4caf50' : '#f44336'}`
184 }}>
185 <div style={{ color: result.type === 'success' ? '#4caf50' : '#f44336', marginBottom: 4 }}>
186 {result.type === 'success' ? 'Validation reussie ! Resultat type :' : mode === 'parse' ? 'parse() a leve une exception :' : 'safeParse() a renvoye des erreurs :'}
187 </div>
188 <pre style={{ color: '#ccc', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{result.data}</pre>
189 </div>
190 )}
191 </div>
192 );
193}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. En quoi safeParse() diffère-t-elle de parse() dans Zod ?
Tâches pratiques dans le jeu
- Éditeur de code
Crée un schéma Zod avec validation des champs (name, email, powerLevel) et intègre-le à React Hook Form via zodResolver
- Tri vertical
Ordonne dans le bon ordre les étapes d'intégration de Zod avec React Hook Form
- Clique dans l'ordre
Ordonne la syntaxe d'import de la bibliothèque Zod
- Éditeur de code
Crée un formulaire de contact avec les champs : name, email, message en utilisant des composants contrôlés et useState