Cours Next.js · Module 6: Formulaires et Server Actions

Validation côté serveur avec les Server Actions

21 min de lecture
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 :

  1. Sécurité - le code JavaScript côté client peut être modifié ou contourné
  2. Cohérence des données - le serveur est la dernière couche de contrôle avant les modifications en base de données
  3. Validation de règles métier complexes - certaines règles peuvent nécessiter l'accès à des données côté serveur
  4. 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:

  1. Le formulaire utilise l'attribut action directement avec la Server Action
  2. Les erreurs de validation sont renvoyées au composant et affichées à l'utilisateur
  3. 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 :

  1. useFormStatus - permet de suivre facilement l'état pending du formulaire
  2. useFormState - assure une gestion automatique de l'état entre le serveur et le client
  3. 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

  1. Combinaison validation client + serveur - valide côté client pour une meilleure UX, mais vérifie toujours côté serveur

  2. Messages d'erreur cohérents - utilise les mêmes messages des deux côtés

  3. Amélioration progressive - assure-toi que les formulaires fonctionnent même sans JavaScript

  4. Typage de bout en bout - utilise l'inférence de types de Zod pour la cohérence entre client et serveur

  5. Revalidation des données - utilise revalidatePath ou revalidateTag après les mutations pour rafraîchir le cache

  6. Structure des erreurs - maintiens un format de réponse cohérent pour les erreurs

  7. Minimise l'état côté client - garde autant de logique que possible sur le serveur

  8. 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. 1. Les Server Actions dans Next.js permettent de :

  2. 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

Articles utiles