Cours Next.js · Module 6: Formulaires et Server Actions
Validation côté serveur avec les Server Actions
Dans cette leçon8
La validation des données côté serveur est un élément indissociable de la sécurité et de l'intégrité des données dans les applications web. Dans le contexte de Next.js, les Server Actions - introduites dans Next.js 13 et améliorées dans les versions suivantes - offrent un mécanisme puissant pour exécuter de la logique côté serveur directement depuis les composants. Dans ce module, nous verrons comment implémenter efficacement la validation à l'aide des Server Actions.
Pourquoi avons-nous besoin de validation côté serveur ?
Malgré les avantages de la validation côté client, la validation côté serveur est nécessaire pour plusieurs raisons :
- Sécurité - le code JavaScript côté client peut être modifié ou contourné
- Cohérence des données - le serveur est la dernière couche de contrôle avant les modifications en base de données
- Validation de règles métier complexes - certaines règles peuvent nécessiter l'accès à des données côté serveur
- Amélioration progressive - garantir le fonctionnement même lorsque JavaScript est désactivé
Les bases des Server Actions en Next.js
Les Server Actions sont des fonctions qui s'exécutent sur le serveur mais peuvent être appelées directement depuis le code côté client. Tu dois les marquer avec la directive 'use server' - soit en haut du fichier (pour que toutes les fonctions du fichier soient des Server Actions), soit avant la déclaration d'une fonction exportée précise.
Structure de base d'une Server Action
1// app/actions.ts
2'use server'; // Toutes les fonctions exportées de ce fichier seront des Server Actions
3
4import { z } from 'zod';
5
6// Schéma de validation
7const ContactSchema = z.object({
8 name: z.string().min(1, { message: 'Le nom est requis' }),
9 email: z.string().email({ message: 'Adresse email invalide' }),
10 message: z.string().min(10, { message: 'Le message doit contenir au moins 10 caractères' })
11});
12
13export async function submitContactForm(formData: FormData) {
14 // Extraire les données de FormData
15 const name = formData.get('name');
16 const email = formData.get('email');
17 const message = formData.get('message');
18
19 // Valider les données avec Zod
20 const result = ContactSchema.safeParse({ name, email, message });
21
22 if (!result.success) {
23 // Renvoyer les erreurs de validation
24 return {
25 success: false,
26 errors: result.error.flatten().fieldErrors
27 };
28 }
29
30 // Traiter les données valides
31 try {
32 // Ici se trouverait le code pour sauvegarder le message, envoyer un email, etc.
33 console.log('Données de formulaire valides :', result.data);
34
35 // Simuler un délai de base de données
36 await new Promise(resolve => setTimeout(resolve, 1000));
37
38 return {
39 success: true,
40 message: 'Le message a été envoyé !'
41 };
42 } catch (error) {
43 console.error('Erreur lors du traitement du formulaire :', error);
44 return {
45 success: false,
46 message: "Une erreur s'est produite lors du traitement du formulaire. Réessaie plus tard."
47 };
48 }
49}Utiliser une Server Action dans un composant
1// app/contact/page.tsx
2'use client';
3
4import { useRef, useState } from 'react';
5import { submitContactForm } from '../actions';
6
7type FormErrors = {
8 name?: string[];
9 email?: string[];
10 message?: string[];
11};
12
13export default function ContactPage() {
14 const formRef = useRef<HTMLFormElement>(null);
15 const [errors, setErrors] = useState<FormErrors>({});
16 const [loading, setLoading] = useState(false);
17 const [success, setSuccess] = useState(false);
18
19 async function handleSubmit(formData: FormData) {
20 setLoading(true);
21 setErrors({});
22 setSuccess(false);
23
24 const result = await submitContactForm(formData);
25
26 setLoading(false);
27
28 if (result.success) {
29 setSuccess(true);
30 formRef.current?.reset();
31 } else if (result.errors) {
32 setErrors(result.errors);
33 } else if (result.message) {
34 alert(result.message); // Peut aussi être affiché dans l'interface
35 }
36 }
37
38 return (
39 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
40 <h1 className="text-2xl font-bold mb-6">Contact</h1>
41
42 {success && (
43 <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
44 Merci pour ton message ! Nous te répondrons dès que possible.
45 </div>
46 )}
47
48 <form action={handleSubmit} ref={formRef} className="space-y-4">
49 <div>
50 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
51 Full name
52 </label>
53 <input
54 type="text"
55 id="name"
56 name="name"
57 className={`mt-1 block w-full rounded-md ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
58 />
59 {errors.name && (
60 <p className="text-red-500 text-xs mt-1">{errors.name[0]}</p>
61 )}
62 </div>
63
64 <div>
65 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
66 Email
67 </label>
68 <input
69 type="email"
70 id="email"
71 name="email"
72 className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
73 />
74 {errors.email && (
75 <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>
76 )}
77 </div>
78
79 <div>
80 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
81 Message
82 </label>
83 <textarea
84 id="message"
85 name="message"
86 rows={4}
87 className={`mt-1 block w-full rounded-md ${errors.message ? 'border-red-500' : 'border-gray-300'}`}
88 />
89 {errors.message && (
90 <p className="text-red-500 text-xs mt-1">{errors.message[0]}</p>
91 )}
92 </div>
93
94 <button
95 type="submit"
96 disabled={loading}
97 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
98 >
99 {loading ? 'Sending...' : 'Send message'}
100 </button>
101 </form>
102 </div>
103 );
104}In this example:
- Le formulaire utilise l'attribut
actiondirectement avec la Server Action - Les erreurs de validation sont renvoyées au composant et affichées à l'utilisateur
- Le formulaire peut être réinitialisé après un envoi réussi
Validation avancée avec des formulaires multi-étapes
Exemple de validation dans un formulaire multi-étapes à l'aide des Server Actions :
1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schéma de validation des données personnelles
7const PersonalDetailsSchema = z.object({
8 firstName: z.string().min(1, { message: 'Le prénom est requis' }),
9 lastName: z.string().min(1, { message: 'Le nom de famille est requis' }),
10 email: z.string().email({ message: 'Adresse email invalide' }),
11 phone: z.string().regex(/^[0-9]{9}$/, { message: 'Phone number must be 9 digits' })
12});
13
14// Schéma de validation de l'adresse de livraison
15const ShippingAddressSchema = z.object({
16 street: z.string().min(1, { message: 'La rue est requise' }),
17 city: z.string().min(1, { message: 'La ville est requise' }),
18 postalCode: z.string().regex(/^\d{2}-\d{3}$/, { message: 'Le code postal doit être au format XX-XXX' }),
19 country: z.string().min(1, { message: 'Le pays est requis' })
20});
21
22// Schéma de validation des données de paiement
23const PaymentDetailsSchema = z.object({
24 cardholderName: z.string().min(1, { message: 'Le nom sur la carte est requis' }),
25 cardNumber: z.string().regex(/^[0-9]{16}$/, { message: 'Le numéro de carte doit comporter 16 chiffres' }),
26 expiryDate: z.string().regex(/^(0[1-9]|1[0-2])\/[0-9]{2}$/, { message: "La date d'expiration doit être au format MM/AA" }),
27 cvv: z.string().regex(/^[0-9]{3}$/, { message: 'Le CVV doit être composé de 3 chiffres' })
28});
29
30// Fonction pour valider les données personnelles
31export async function validatePersonalDetails(formData: FormData) {
32 // Extraire les données
33 const data = {
34 firstName: formData.get('firstName'),
35 lastName: formData.get('lastName'),
36 email: formData.get('email'),
37 phone: formData.get('phone')
38 };
39
40 // Validation
41 const result = PersonalDetailsSchema.safeParse(data);
42
43 if (!result.success) {
44 return {
45 success: false,
46 errors: result.error.flatten().fieldErrors
47 };
48 }
49
50 // Facultatif : sauvegarder les données en session
51 // await saveToSession('personalDetails', result.data);
52
53 return {
54 success: true,
55 data: result.data
56 };
57}
58
59// Fonction pour valider l'adresse de livraison
60export async function validateShippingAddress(formData: FormData) {
61 const data = {
62 street: formData.get('street'),
63 city: formData.get('city'),
64 postalCode: formData.get('postalCode'),
65 country: formData.get('country')
66 };
67
68 const result = ShippingAddressSchema.safeParse(data);
69
70 if (!result.success) {
71 return {
72 success: false,
73 errors: result.error.flatten().fieldErrors
74 };
75 }
76
77 return {
78 success: true,
79 data: result.data
80 };
81}
82
83// Fonction pour valider les données de paiement
84export async function validatePaymentDetails(formData: FormData) {
85 const data = {
86 cardholderName: formData.get('cardholderName'),
87 cardNumber: formData.get('cardNumber'),
88 expiryDate: formData.get('expiryDate'),
89 cvv: formData.get('cvv')
90 };
91
92 const result = PaymentDetailsSchema.safeParse(data);
93
94 if (!result.success) {
95 return {
96 success: false,
97 errors: result.error.flatten().fieldErrors
98 };
99 }
100
101 return {
102 success: true,
103 data: result.data
104 };
105}
106
107// Fonction pour finaliser la commande
108export async function submitOrder(personalDetails: any, shippingAddress: any, paymentDetails: any) {
109 try {
110 // Ici se trouverait le code pour traiter le paiement et enregistrer la commande
111 console.log('Traitement de la commande :', { personalDetails, shippingAddress, paymentDetails });
112
113 // Simuler un délai
114 await new Promise(resolve => setTimeout(resolve, 1500));
115
116 // Générer un numéro de commande
117 const orderNumber = Math.floor(100000 + Math.random() * 900000).toString();
118
119 return {
120 success: true,
121 orderNumber
122 };
123 } catch (error) {
124 console.error('Erreur lors du traitement de la commande :', error);
125 return {
126 success: false,
127 message: "Une erreur s'est produite lors du traitement de la commande. Réessaie plus tard."
128 };
129 }
130}Et le composant client utilisant ces actions :
1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { validatePersonalDetails, validateShippingAddress, validatePaymentDetails, submitOrder } from './actions';
6
7// Types d'erreurs pour chaque étape
8type FormErrors = Record<string, string[]>;
9
10export default function CheckoutPage() {
11 // États pour suivre l'étape, les données et les erreurs
12 const [currentStep, setCurrentStep] = useState(1);
13 const [loading, setLoading] = useState(false);
14 const [errors, setErrors] = useState<FormErrors>({});
15 const [formData, setFormData] = useState({
16 personalDetails: null,
17 shippingAddress: null,
18 paymentDetails: null
19 });
20 const [orderResult, setOrderResult] = useState<{ success: boolean; orderNumber?: string; message?: string } | null>(null);
21
22 // Fonction pour gérer l'étape 1 - données personnelles
23 async function handlePersonalDetailsSubmit(formData: FormData) {
24 setLoading(true);
25 setErrors({});
26
27 const result = await validatePersonalDetails(formData);
28
29 setLoading(false);
30
31 if (result.success) {
32 setFormData(prev => ({ ...prev, personalDetails: result.data }));
33 setCurrentStep(2);
34 } else if (result.errors) {
35 setErrors(result.errors);
36 }
37 }
38
39 // Fonction pour gérer l'étape 2 - adresse de livraison
40 async function handleShippingAddressSubmit(formData: FormData) {
41 setLoading(true);
42 setErrors({});
43
44 const result = await validateShippingAddress(formData);
45
46 setLoading(false);
47
48 if (result.success) {
49 setFormData(prev => ({ ...prev, shippingAddress: result.data }));
50 setCurrentStep(3);
51 } else if (result.errors) {
52 setErrors(result.errors);
53 }
54 }
55
56 // Fonction pour gérer l'étape 3 - données de paiement
57 async function handlePaymentDetailsSubmit(formData: FormData) {
58 setLoading(true);
59 setErrors({});
60
61 const result = await validatePaymentDetails(formData);
62
63 setLoading(false);
64
65 if (result.success) {
66 setFormData(prev => ({ ...prev, paymentDetails: result.data }));
67
68 // Après avoir validé toutes les étapes, nous envoyons la commande
69 await finalizeOrder(result.data);
70 } else if (result.errors) {
71 setErrors(result.errors);
72 }
73 }
74
75 // Finaliser la commande
76 async function finalizeOrder(paymentDetails: any) {
77 setLoading(true);
78
79 const { personalDetails, shippingAddress } = formData;
80 const result = await submitOrder(personalDetails, shippingAddress, paymentDetails);
81
82 setLoading(false);
83 setOrderResult(result);
84
85 if (result.success) {
86 setCurrentStep(4); // Passer à la confirmation
87 }
88 }
89
90 // Revenir à l'étape précédente
91 function goToPreviousStep() {
92 setCurrentStep(prev => Math.max(1, prev - 1));
93 setErrors({});
94 }
95
96 // Réinitialiser le formulaire
97 function resetForm() {
98 setCurrentStep(1);
99 setFormData({
100 personalDetails: null,
101 shippingAddress: null,
102 paymentDetails: null
103 });
104 setErrors({});
105 setOrderResult(null);
106 }
107
108 return (
109 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
110 <h1 className="text-2xl font-bold mb-6">Traitement de la commande</h1>
111
112 {/* Barre de progression */}
113 <div className="mb-6">
114 <div className="flex justify-between">
115 {['Données personnelles', 'Adresse de livraison', 'Paiement', 'Confirmation'].map((step, index) => (
116 <div key={index} className="flex flex-col items-center">
117 <div className={`h-8 w-8 rounded-full flex items-center justify-center text-sm ${currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
118 {currentStep > index + 1 ? '√' : index + 1}
119 </div>
120 <span className="text-xs mt-1">{step}</span>
121 </div>
122 ))}
123 </div>
124 <div className="mt-2 h-2 bg-gray-200 rounded-full">
125 <div
126 className="h-full bg-blue-600 rounded-full transition-all duration-300"
127 style={{ width: `${Math.min(100, ((currentStep - 1) / 3) * 100)}%` }}
128 ></div>
129 </div>
130 </div>
131
132 {/* Étape 1 : Données personnelles */}
133 {currentStep === 1 && (
134 <form action={handlePersonalDetailsSubmit} className="space-y-4">
135 <div>
136 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">Prénom</label>
137 <input
138 type="text"
139 id="firstName"
140 name="firstName"
141 defaultValue={formData.personalDetails?.firstName || ''}
142 className={`mt-1 block w-full rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
143 />
144 {errors.firstName && <p className="text-red-500 text-xs mt-1">{errors.firstName[0]}</p>}
145 </div>
146
147 <div>
148 <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">Nom de famille</label>
149 <input
150 type="text"
151 id="lastName"
152 name="lastName"
153 defaultValue={formData.personalDetails?.lastName || ''}
154 className={`mt-1 block w-full rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
155 />
156 {errors.lastName && <p className="text-red-500 text-xs mt-1">{errors.lastName[0]}</p>}
157 </div>
158
159 <div>
160 <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
161 <input
162 type="email"
163 id="email"
164 name="email"
165 defaultValue={formData.personalDetails?.email || ''}
166 className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
167 />
168 {errors.email && <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>}
169 </div>
170
171 <div>
172 <label htmlFor="phone" className="block text-sm font-medium text-gray-700">Téléphone</label>
173 <input
174 type="tel"
175 id="phone"
176 name="phone"
177 defaultValue={formData.personalDetails?.phone || ''}
178 className={`mt-1 block w-full rounded-md ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
179 />
180 {errors.phone && <p className="text-red-500 text-xs mt-1">{errors.phone[0]}</p>}
181 </div>
182
183 <button
184 type="submit"
185 disabled={loading}
186 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
187 >
188 {loading ? 'Traitement...' : 'Suivant'}
189 </button>
190 </form>
191 )}
192
193 {/* Étape 2 : Adresse de livraison */}
194 {currentStep === 2 && (
195 <form action={handleShippingAddressSubmit} className="space-y-4">
196 <div>
197 <label htmlFor="street" className="block text-sm font-medium text-gray-700">Rue et numéro</label>
198 <input
199 type="text"
200 id="street"
201 name="street"
202 defaultValue={formData.shippingAddress?.street || ''}
203 className={`mt-1 block w-full rounded-md ${errors.street ? 'border-red-500' : 'border-gray-300'}`}
204 />
205 {errors.street && <p className="text-red-500 text-xs mt-1">{errors.street[0]}</p>}
206 </div>
207
208 <div>
209 <label htmlFor="city" className="block text-sm font-medium text-gray-700">Ville</label>
210 <input
211 type="text"
212 id="city"
213 name="city"
214 defaultValue={formData.shippingAddress?.city || ''}
215 className={`mt-1 block w-full rounded-md ${errors.city ? 'border-red-500' : 'border-gray-300'}`}
216 />
217 {errors.city && <p className="text-red-500 text-xs mt-1">{errors.city[0]}</p>}
218 </div>
219
220 <div>
221 <label htmlFor="postalCode" className="block text-sm font-medium text-gray-700">Code postal</label>
222 <input
223 type="text"
224 id="postalCode"
225 name="postalCode"
226 defaultValue={formData.shippingAddress?.postalCode || ''}
227 className={`mt-1 block w-full rounded-md ${errors.postalCode ? 'border-red-500' : 'border-gray-300'}`}
228 />
229 {errors.postalCode && <p className="text-red-500 text-xs mt-1">{errors.postalCode[0]}</p>}
230 </div>
231
232 <div>
233 <label htmlFor="country" className="block text-sm font-medium text-gray-700">Pays</label>
234 <select
235 id="country"
236 name="country"
237 defaultValue={formData.shippingAddress?.country || ''}
238 className={`mt-1 block w-full rounded-md ${errors.country ? 'border-red-500' : 'border-gray-300'}`}
239 >
240 <option value="">Sélectionne un pays</option>
241 <option value="Poland">Pologne</option>
242 <option value="Germany">Allemagne</option>
243 <option value="UK">Royaume-Uni</option>
244 <option value="France">France</option>
245 </select>
246 {errors.country && <p className="text-red-500 text-xs mt-1">{errors.country[0]}</p>}
247 </div>
248
249 <div className="flex justify-between">
250 <button
251 type="button"
252 onClick={goToPreviousStep}
253 className="py-2 px-4 border border-gray-300 rounded-md hover:bg-gray-50"
254 >
255 Back
256 </button>
257
258 <button
259 type="submit"
260 disabled={loading}
261 className="py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
262 >
263 {loading ? 'Traitement...' : 'Suivant'}
264 </button>
265 </div>
266 </form>
267 )}
268
269 {/* Étape 3 : Données de paiement */}
270 {currentStep === 3 && (
271 <form action={handlePaymentDetailsSubmit} className="space-y-4">
272 <div>
273 <label htmlFor="cardholderName" className="block text-sm font-medium text-gray-700">Nom sur la carte</label>
274 <input
275 type="text"
276 id="cardholderName"
277 name="cardholderName"
278 className={`mt-1 block w-full rounded-md ${errors.cardholderName ? 'border-red-500' : 'border-gray-300'}`}
279 />
280 {errors.cardholderName && <p className="text-red-500 text-xs mt-1">{errors.cardholderName[0]}</p>}
281 </div>
282
283 <div>
284 <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">Numéro de carte</label>
285 <input
286 type="text"
287 id="cardNumber"
288 name="cardNumber"
289 className={`mt-1 block w-full rounded-md ${errors.cardNumber ? 'border-red-500' : 'border-gray-300'}`}
290 />
291 {errors.cardNumber && <p className="text-red-500 text-xs mt-1">{errors.cardNumber[0]}</p>}
292 </div>
293
294 <div className="grid grid-cols-2 gap-4">
295 <div>
296 <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">Date d'expiration</label>
297 <input
298 type="text"
299 id="expiryDate"
300 name="expiryDate"
301 className={`mt-1 block w-full rounded-md ${errors.expiryDate ? 'border-red-500' : 'border-gray-300'}`}
302 />
303 {errors.expiryDate && <p className="text-red-500 text-xs mt-1">{errors.expiryDate[0]}</p>}
304 </div>
305
306 <div>
307 <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">CVV</label>
308 <input
309 type="text"
310 id="cvv"
311 name="cvv"
312 className={`mt-1 block w-full rounded-md ${errors.cvv ? 'border-red-500' : 'border-gray-300'}`}
313 />
314 {errors.cvv && <p className="text-red-500 text-xs mt-1">{errors.cvv[0]}</p>}
315 </div>
316 </div>
317
318 <div className="flex justify-between">
319 <button
320 type="button"
321 onClick={goToPreviousStep}
322 className="py-2 px-4 border border-gray-300 rounded-md hover:bg-gray-50"
323 >
324 Retour
325 </button>
326
327 <button
328 type="submit"
329 disabled={loading}
330 className="py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
331 >
332 {loading ? 'Traitement du paiement...' : 'Passer la commande'}
333 </button>
334 </div>
335 </form>
336 )}
337
338 {/* Étape 4 : Confirmation */}
339 {currentStep === 4 && orderResult?.success && (
340 <div className="text-center py-8">
341 <div className="mb-4 w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto">
342 <svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
343 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
344 </svg>
345 </div>
346
347 <h2 className="text-2xl font-bold mb-2">Commande acceptée !</h2>
348 <p className="text-gray-600 mb-1">Thank you for placing your order.</p>
349 <p className="text-gray-600 mb-4">Numéro de commande : <span className="font-medium">{orderResult.orderNumber}</span></p>
350
351 <p className="text-sm text-gray-500 mb-6">Les détails de la commande ont été envoyés à l'email : <br />{formData.personalDetails?.email}</p>
352
353 <button
354 onClick={resetForm}
355 className="inline-block py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
356 >
357 Passer une nouvelle commande
358 </button>
359 </div>
360 )}
361
362 {/* Message d'erreur de finalisation */}
363 {currentStep === 3 && orderResult?.success === false && (
364 <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
365 {orderResult.message || "Une erreur s'est produite lors du traitement de la commande."}
366 </div>
367 )}
368 </div>
369 );
370}Utiliser useFormStatus et useFormState
Next.js fournit les hooks useFormStatus et useFormState, qui offrent une meilleure intégration des Server Actions dans les formulaires :
1// app/contact/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schéma de validation
7const ContactSchema = z.object({
8 name: z.string().min(1, { message: 'Le nom est requis' }),
9 email: z.string().email({ message: 'Adresse email invalide' }),
10 message: z.string().min(10, { message: 'Le message doit contenir au moins 10 caractères' })
11});
12
13// Type de l'état du formulaire
14type FormState = {
15 errors?: {
16 name?: string[];
17 email?: string[];
18 message?: string[];
19 };
20 message?: string;
21 success?: boolean;
22};
23
24// Server Action avec un format de retour pour useFormState
25export async function submitContactForm(prevState: FormState, formData: FormData): Promise<FormState> {
26 // Extraire les données
27 const name = formData.get('name');
28 const email = formData.get('email');
29 const message = formData.get('message');
30
31 // Validation
32 const result = ContactSchema.safeParse({ name, email, message });
33
34 if (!result.success) {
35 return {
36 errors: result.error.flatten().fieldErrors,
37 message: 'Corrige les erreurs du formulaire',
38 success: false
39 };
40 }
41
42 try {
43 // Sauvegarder le message en base de données, envoyer un email, etc.
44 await new Promise(resolve => setTimeout(resolve, 1000));
45
46 // Dans une vraie application, ici se trouverait le code de traitement du formulaire
47 console.log('Message de contact :', result.data);
48
49 return {
50 errors: {},
51 message: 'Le message a été envoyé !',
52 success: true
53 };
54 } catch (error) {
55 return {
56 errors: {},
57 message: "Une erreur s'est produite. Réessaie plus tard.",
58 success: false
59 };
60 }
61}Et le composant de formulaire correspondant :
1// app/contact/page.tsx
2'use client';
3
4import { useFormState, useFormStatus } from 'react-dom';
5import { submitContactForm } from './actions';
6
7// Composant bouton avec gestion de l'état du formulaire
8function SubmitButton() {
9 // useFormStatus donne accès à l'état du formulaire, y compris pending
10 const { pending } = useFormStatus();
11
12 return (
13 <button
14 type="submit"
15 disabled={pending}
16 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
17 >
18 {pending ? 'Envoi...' : 'Envoyer le message'}
19 </button>
20 );
21}
22
23export default function ContactPage() {
24 // Initialiser l'état du formulaire
25 const initialState = { errors: {}, message: '', success: false };
26
27 // useFormState (connecte le formulaire à la Server Action)
28 const [state, formAction] = useFormState(submitContactForm, initialState);
29
30 // Réinitialiser le formulaire after successful submission
31 const formRef = React.useRef<HTMLFormElement>(null);
32
33 React.useEffect(() => {
34 if (state.success && formRef.current) {
35 formRef.current.reset();
36 }
37 }, [state.success]);
38
39 return (
40 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
41 <h1 className="text-2xl font-bold mb-6">Contact</h1>
42
43 {state.message && (
44 <div className={`mb-4 p-3 rounded ${state.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
45 {state.message}
46 </div>
47 )}
48
49 <form ref={formRef} action={formAction} className="space-y-4">
50 <div>
51 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
52 Full name
53 </label>
54 <input
55 type="text"
56 id="name"
57 name="name"
58 className={`mt-1 block w-full rounded-md ${state.errors?.name ? 'border-red-500' : 'border-gray-300'}`}
59 />
60 {state.errors?.name && (
61 <p className="text-red-500 text-xs mt-1">{state.errors.name[0]}</p>
62 )}
63 </div>
64
65 <div>
66 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
67 Email
68 </label>
69 <input
70 type="email"
71 id="email"
72 name="email"
73 className={`mt-1 block w-full rounded-md ${state.errors?.email ? 'border-red-500' : 'border-gray-300'}`}
74 />
75 {state.errors?.email && (
76 <p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
77 )}
78 </div>
79
80 <div>
81 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
82 Message
83 </label>
84 <textarea
85 id="message"
86 name="message"
87 rows={4}
88 className={`mt-1 block w-full rounded-md ${state.errors?.message ? 'border-red-500' : 'border-gray-300'}`}
89 />
90 {state.errors?.message && (
91 <p className="text-red-500 text-xs mt-1">{state.errors.message[0]}</p>
92 )}
93 </div>
94
95 <SubmitButton />
96 </form>
97 </div>
98 );
99}Principaux avantages de cette approche :
- useFormStatus - permet de suivre facilement l'état pending du formulaire
- useFormState - assure une gestion automatique de l'état entre le serveur et le client
- Amélioration progressive - le formulaire fonctionne même sans JavaScript
Validation avec TypeScript et Zod
Zod s'intègre parfaitement avec TypeScript, te permettant de définir des schémas de validation qui servent en même temps de types :
1// app/lib/validationSchemas.ts
2import { z } from 'zod';
3
4// Schéma de produit
5export const ProductSchema = z.object({
6 name: z.string().min(1, { message: 'Le nom du produit est requis' }),
7 price: z.coerce.number().min(0.01, { message: 'Le prix doit être supérieur à 0' }),
8 description: z.string().min(10, { message: 'La description doit contenir au moins 10 caractères' }),
9 category: z.enum(['electronics', 'clothing', 'food', 'books'], {
10 errorMap: () => ({ message: 'Sélectionne une catégorie valide' })
11 }),
12 quantity: z.coerce.number().int().min(0, { message: 'La quantité ne peut pas être négative' }),
13 featured: z.boolean().default(false)
14});
15
16// Type de produit inféré depuis le schéma
17export type Product = z.infer<typeof ProductSchema>;
18
19// Schéma pour l'édition du produit (tous les champs facultatifs)
20export const ProductUpdateSchema = ProductSchema.partial();
21export type ProductUpdate = z.infer<typeof ProductUpdateSchema>;Utilisation dans une Server Action :
1// app/products/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { ProductSchema, ProductUpdateSchema, type Product } from '../lib/validationSchemas';
6
7// Fonction pour ajouter un nouveau produit
8export async function addProduct(formData: FormData) {
9 // Convertir FormData en objet
10 const rawData = Object.fromEntries(formData.entries());
11
12 // Convertir le champ 'featured' en booléen (case à cocher)
13 const productData = {
14 ...rawData,
15 featured: formData.get('featured') === 'on'
16 };
17
18 // Valider les données
19 const validationResult = ProductSchema.safeParse(productData);
20
21 if (!validationResult.success) {
22 return {
23 success: false,
24 errors: validationResult.error.flatten().fieldErrors
25 };
26 }
27
28 try {
29 // Dans une vraie application : sauvegarder le produit en base de données
30 // const newProduct = await db.product.create({ data: validationResult.data });
31 console.log('Nouveau produit ajouté :', validationResult.data);
32
33 // Revalider le chemin pour rafraîchir le cache
34 revalidatePath('/products');
35
36 return {
37 success: true,
38 message: 'Le produit a été ajouté avec succès !'
39 };
40 } catch (error) {
41 console.error("Erreur lors de l'ajout du produit :", error);
42 return {
43 success: false,
44 message: "Une erreur s'est produite lors de l'ajout du produit."
45 };
46 }
47}
48
49// Fonction pour mettre à jour un produit (tous les champs facultatifs)
50export async function updateProduct(id: string, formData: FormData) {
51 const rawData = Object.fromEntries(formData.entries());
52
53 // Supprimer les champs vides qui ne doivent pas être mis à jour
54 for (const [key, value] of Object.entries(rawData)) {
55 if (value === '') {
56 delete rawData[key];
57 }
58 }
59
60 // Convertir le champ 'featured' en booléen (case à cocher)
61 const productData = {
62 ...rawData,
63 featured: formData.has('featured')
64 };
65
66 // Valider la mise à jour partielle
67 const validationResult = ProductUpdateSchema.safeParse(productData);
68
69 if (!validationResult.success) {
70 return {
71 success: false,
72 errors: validationResult.error.flatten().fieldErrors
73 };
74 }
75
76 try {
77 // Dans une vraie application : mettre à jour le produit en base de données
78 // const updatedProduct = await db.product.update({
79 // where: { id },
80 // data: validationResult.data
81 // });
82 console.log(`Produit ${id} mis à jour :`, validationResult.data);
83
84 // Revalider le chemin pour rafraîchir le cache
85 revalidatePath(`/products/${id}`);
86 revalidatePath('/products');
87
88 return {
89 success: true,
90 message: 'Le produit a été mis à jour avec succès !'
91 };
92 } catch (error) {
93 console.error('Erreur lors de la mise à jour du produit :', error);
94 return {
95 success: false,
96 message: "Une erreur s'est produite lors de la mise à jour du produit."
97 };
98 }
99}Sécurité dans les Server Actions
Lors de l'implémentation de la validation dans les Server Actions, plusieurs considérations de sécurité clés doivent être gardées à l'esprit :
1. Protection CSRF (Cross-Site Request Forgery)
Next.js protège automatiquement les Server Actions contre les attaques CSRF grâce aux « Action IDs » et à d'autres mécanismes. Il est cependant bon d'être conscient de cette protection et d'appliquer des garde-fous supplémentaires pour les opérations critiques.
2. Limitation du débit (Rate Limiting)
Pour les opérations sensibles (par ex. connexion, inscription), il est utile d'appliquer une limite sur le nombre de requêtes provenant d'une même adresse IP :
1'use server';
2
3import { cookies } from 'next/headers';
4
5// Implémentation simple de limitation de débit basée sur les cookies
6export async function loginUser(formData: FormData) {
7 const cookieStore = await cookies();
8 const attemptsCookie = cookieStore.get('login_attempts');
9
10 // Vérifier le nombre de tentatives de connexion
11 const attempts = attemptsCookie ? parseInt(attemptsCookie.value, 10) : 0;
12
13 if (attempts >= 5) {
14 return {
15 success: false,
16 message: 'Trop de tentatives de connexion. Réessaie plus tard.'
17 };
18 }
19
20 // Reste de la logique de connexion...
21
22 // En cas d'échec de connexion, incrémenter le compteur
23 if (!success) {
24 (await cookies()).set('login_attempts', String(attempts + 1), {
25 maxAge: 60 * 15, // 15 minutes
26 httpOnly: true,
27 secure: process.env.NODE_ENV === 'production',
28 sameSite: 'strict'
29 });
30 } else {
31 // Réinitialiser le compteur après une connexion réussie
32 (await cookies()).set('login_attempts', '0');
33 }
34
35 //
36}3. Nettoyage des données saisies
Nettoie et valide toujours les données saisies avant de les traiter :
1'use server';
2
3import { z } from 'zod';
4import { sanitizeHtml } from 'some-html-sanitizer';
5
6export async function addComment(formData: FormData) {
7 // Récupération et premier nettoyage
8 const rawComment = formData.get('comment')?.toString() || '';
9 const sanitizedComment = sanitizeHtml(rawComment, {
10 allowedTags: ['b', 'i', 'em', 'strong'],
11 allowedAttributes: {}
12 });
13
14 // Validation après le nettoyage
15 const commentSchema = z.string().min(5).max(1000);
16 const result = commentSchema.safeParse(sanitizedComment);
17
18 if (!result.success) {
19 return { success: false, errors: result.error.flatten().formErrors };
20 }
21
22 // Sauvegarder en base de données...
23}4. Autorisation dans les Server Actions
Vérifie toujours les permissions de l'utilisateur avant d'exécuter une opération :
1'use server';
2
3import { getServerSession } from 'next-auth';
4import { authOptions } from '../auth';
5
6export async function deleteProduct(productId: string) {
7 // Récupérer la session de l'utilisateur
8 const session = await getServerSession(authOptions);
9
10 // Vérifier si l'utilisateur est connecté
11 if (!session?.user) {
12 return {
13 success: false,
14 message: 'Tu dois être connecté pour effectuer cette opération.'
15 };
16 }
17
18 // Vérifier des permissions supplémentaires (par ex. le rôle admin)
19 if (session.user.role !== 'admin') {
20 return {
21 success: false,
22 message: "Tu n'as pas la permission d'effectuer cette opération."
23 };
24 }
25
26 // Exécuter l'opération...
27}Bonnes pratiques pour la validation des données dans les Server Actions
Combinaison validation client + serveur - valide côté client pour une meilleure UX, mais vérifie toujours côté serveur
Messages d'erreur cohérents - utilise les mêmes messages des deux côtés
Amélioration progressive - assure-toi que les formulaires fonctionnent même sans JavaScript
Typage de bout en bout - utilise l'inférence de types de Zod pour la cohérence entre client et serveur
Revalidation des données - utilise
revalidatePathourevalidateTagaprès les mutations pour rafraîchir le cacheStructure des erreurs - maintiens un format de réponse cohérent pour les erreurs
Minimise l'état côté client - garde autant de logique que possible sur le serveur
Pense à l'accessibilité - utilise des descriptions d'erreurs appropriées et des attributs ARIA
Résumé
Les Server Actions de Next.js fournissent un mécanisme puissant pour implémenter une validation côté serveur qui allie avantages de sécurité et bonne expérience utilisateur :
- Sécurité - les données sont toujours validées sur le serveur, quel que soit l'état de JavaScript côté client
- Performances - pas besoin de créer des points d'entrée API séparés
- Amélioration progressive - les formulaires fonctionnent même sans JavaScript
- Cohérence des types - la possibilité de partager les schémas de validation entre client et serveur
En appliquant les techniques et bonnes pratiques décrites dans ce module, tu peux construire des formulaires robustes, sécurisés et conviviaux qui tirent pleinement parti des capacités de Next.js.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Simulation de la validation serveur avec les Server Actions dans Next.js
4
5type ServerState = {
6 status: 'idle' | 'pending' | 'success' | 'error';
7 errors: Record<string, string>;
8 message: string;
9};
10
11// Simulation d'une Server Action
12async function simulateServerAction(formData: Record<string, string>): Promise<ServerState> {
13 // Simulation du delai serveur
14 await new Promise(r => setTimeout(r, 1000));
15
16 const errors: Record<string, string> = {};
17
18 // Validation serveur - independante du client
19 if (!formData.title || formData.title.length < 3) {
20 errors.title = 'Le titre doit comporter au moins 3 caracteres';
21 }
22 if (!formData.content || formData.content.length < 10) {
23 errors.content = 'Le contenu doit comporter au moins 10 caracteres';
24 }
25 if (formData.title?.toLowerCase().includes('test')) {
26 errors.title = 'Le serveur a refuse : le mot "test" est interdit';
27 }
28
29 if (Object.keys(errors).length > 0) {
30 return { status: 'error', errors, message: 'La validation serveur a echoue' };
31 }
32
33 return { status: 'success', errors: {}, message: 'Donnees enregistrees sur le serveur !' };
34}
35
36export default function ServerValidationDemo() {
37 const [values, setValues] = useState({ title: '', content: '' });
38 const [state, setState] = useState<ServerState>({ status: 'idle', errors: {}, message: '' });
39
40 const handleSubmit = async (e: React.FormEvent) => {
41 e.preventDefault();
42 setState({ status: 'pending', errors: {}, message: 'Validation sur le serveur...' });
43 const result = await simulateServerAction(values);
44 setState(result);
45 };
46
47 const inputStyle: React.CSSProperties = {
48 width: '100%', padding: '10px', background: '#0d1117',
49 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
50 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
51 };
52
53 return (
54 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
55 <h1 style={{ color: '#64ffda' }}>Server Actions - validation serveur</h1>
56 <p style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
57 Les donnees sont validees sur le serveur - independamment du JavaScript du client.
58 Essaie d'utiliser le mot "test" dans le titre !
59 </p>
60
61 <div style={{ background: '#0d1117', borderRadius: 8, padding: 12, marginBottom: 16 }}>
62 <pre style={{ color: '#ce93d8', fontSize: 11, margin: 0 }}>
63{/* Exemple de Server Action */}
64{'// app/actions.ts\n"use server"\n\nexport async function createPost(formData: FormData) {\n const title = formData.get("title");\n // validation sur le serveur...\n}'}
65 </pre>
66 </div>
67
68 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
69 <div style={{ marginBottom: 12 }}>
70 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Titre</label>
71 <input
72 value={values.title}
73 onChange={e => setValues(p => ({ ...p, title: e.target.value }))}
74 style={{ ...inputStyle, borderColor: state.errors.title ? '#f44336' : '#333' }}
75 />
76 {state.errors.title && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.title}</div>}
77 </div>
78
79 <div style={{ marginBottom: 12 }}>
80 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Contenu (min 10 caracteres)</label>
81 <textarea
82 value={values.content}
83 onChange={e => setValues(p => ({ ...p, content: e.target.value }))}
84 rows={3}
85 style={{ ...inputStyle, resize: 'vertical', borderColor: state.errors.content ? '#f44336' : '#333' }}
86 />
87 {state.errors.content && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.content}</div>}
88 </div>
89
90 <button type="submit" disabled={state.status === 'pending'} style={{
91 background: state.status === 'pending' ? '#555' : '#ff9800',
92 color: '#fff', border: 'none', borderRadius: 6,
93 padding: '10px 20px', cursor: state.status === 'pending' ? 'wait' : 'pointer',
94 fontFamily: 'monospace', width: '100%'
95 }}>
96 {state.status === 'pending' ? 'Validation sur le serveur...' : 'Envoyer (Server Action)'}
97 </button>
98 </form>
99
100 {state.message && state.status !== 'idle' && (
101 <div style={{
102 marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
103 background: state.status === 'success' ? '#1b5e20' : state.status === 'error' ? '#3e2723' : '#1a2744',
104 border: `1px solid ${state.status === 'success' ? '#4caf50' : state.status === 'error' ? '#f44336' : '#2196f3'}`,
105 }}>
106 <div style={{ color: state.status === 'success' ? '#4caf50' : state.status === 'error' ? '#f44336' : '#2196f3' }}>
107 {state.message}
108 </div>
109 </div>
110 )}
111 </div>
112 );
113}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. Les Server Actions dans Next.js permettent de :
2. Formik est :
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée un formulaire d'inscription avec React Hook Form et les champs : username, email, password, confirmPassword
- Tri horizontal
Ordonne la syntaxe d'un input avec register dans React Hook Form
- Clique dans l'ordre
Ordonne la syntaxe de définition d'un schéma Zod
- Éditeur de code
Crée un schéma Zod pour un formulaire utilisateur avec validation d'e-mail, mot de passe fort, longueurs min/max
- Éditeur de code
Crée une Server Action pour traiter un formulaire de contact avec validation et envoi d'e-mail
- Tri vertical
Ordonne les éléments dans le bon ordre : Installer le paquet → Importer les composants → Personnaliser le thème → Utiliser dans l'app
- Clique dans l'ordre
Ordonne la syntaxe d'enregistrement d'un input dans React Hook Form
- Éditeur de code
Crée un formulaire d'inscription multi-étapes avec les étapes : Personal Info, Account Details, Preferences