Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Walidacja po stronie serwera z Server Actions

Walidacja danych po stronie serwera jest nieodłącznym elementem bezpieczeństwa i integralności danych w aplikacjach webowych. W kontekscie Next.js, Server Actions - wprowadzone w Next.js 13 i ulepszone w kolejnych wersjach - oferują potężny mechanizm do wykonywania logiki po stronie serwera bezpośrednio z komponentów. W tym module omowimy, jak efektywnie implementować walidację z wykorzystaniem Server Actions.

Dlaczego potrzebujemy walidacji po stronie serwera?

Pomimo zalet walidacji po stronie klienta, walidacja serwerowa jest konieczna z kilku powodów:

  1. Bezpieczeństwo - kod JavaScript po stronie klienta może zostać zmodyfikowany lub pominięty
  2. Spójność danych - serwer jest ostateczną warstwą kontroli przed modyfikacją bazy danych
  3. Walidacja złożonych reguł biznesowych - niektore reguly mogą wymagać dostępu do danych na serwerze
  4. Progresywne ulepszanie - zapewnienie funkcjonalności nawet gdy JavaScript jest wyłączony

Podstawy Server Actions w Next.js

Server Actions to funkcje, które działają na serwerze, ale mogą być wywoływane bezpośrednio z kodu po stronie klienta. Trzeba zaznaczyć je dyrektywą

'use server'
- albo na początku pliku (aby wszystkie funkcje w pliku były Server Actions), albo przed konkretnym eksportowanym wyrzęzeniem funkcji.

Podstawowa struktura Server Action

1// app/actions.ts
2'use server'; // Wszystkie funkcje eksportowane z tego pliku będą Server Actions
3
4import { z } from 'zod';
5
6// Schemat walidacji
7const ContactSchema = z.object({
8  name: z.string().min(1, { message: 'Imię jest wymagane' }),
9  email: z.string().email({ message: 'Nieprawidłowy adres email' }),
10  message: z.string().min(10, { message: 'Wiadomość musi mieć co najmniej 10 znaków' })
11});
12
13export async function submitContactForm(formData: FormData) {
14  // Ekstrakcja danych z FormData
15  const name = formData.get('name');
16  const email = formData.get('email');
17  const message = formData.get('message');
18  
19  // Walidacja danych za pomocą Zod
20  const result = ContactSchema.safeParse({ name, email, message });
21  
22  if (!result.success) {
23    // Zwracamy błędy walidacji
24    return {
25      success: false,
26      errors: result.error.flatten().fieldErrors
27    };
28  }
29  
30  // Przetwarzanie poprawnych danych
31  try {
32    // Tutaj byłby kod do zapisania wiadomości, wysyłki emaila itp.
33    console.log('Poprawne dane formularza:', result.data);
34    
35    // Symulacja opóźnienia bazy danych
36    await new Promise(resolve => setTimeout(resolve, 1000));
37    
38    return {
39      success: true,
40      message: 'Wiadomość została wysłana!'
41    };
42  } catch (error) {
43    console.error('Błąd podczas przetwarzania formularza:', error);
44    return {
45      success: false,
46      message: 'Wystąpił błąd podczas przetwarzania formularza. Spróbuj ponownie później.'
47    };
48  }
49}

Użycie Server Action w komponencie

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); // Alternatywnie można wyświetlić w UI
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">Kontakt</h1>
41      
42      {success && (
43        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
44          Dziękujemy za wiadomość! Odpowiemy najszybciej jak to możliwe.
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            Imię i nazwisko
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            Wiadomość
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 ? 'Wysyłanie...' : 'Wyślij wiadomość'}
100        </button>
101      </form>
102    </div>
103  );
104}

W tym przykładzie:

  1. Formularz używa atrybutu
    action
    bezpośrednio z Server Action
  2. Błędy walidacji są zwracane do komponentu i wyświetlane użytkownikowi
  3. Formularz może być zresetowany po pomyślnym wysłaniu

Zaawansowana walidacja z formularzami wielostopniowymi

Przykład walidacji w wielostopniowym formularzu z użyciem Server Actions:

1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schemat walidacji dla danych osobowych
7const PersonalDetailsSchema = z.object({
8  firstName: z.string().min(1, { message: 'Imię jest wymagane' }),
9  lastName: z.string().min(1, { message: 'Nazwisko jest wymagane' }),
10  email: z.string().email({ message: 'Nieprawidłowy adres email' }),
11  phone: z.string().regex(/^[0-9]{9}$/, { message: 'Numer telefonu musi mieć 9 cyfr' })
12});
13
14// Schemat walidacji dla adresu dostawy
15const ShippingAddressSchema = z.object({
16  street: z.string().min(1, { message: 'Ulica jest wymagana' }),
17  city: z.string().min(1, { message: 'Miasto jest wymagane' }),
18  postalCode: z.string().regex(/^d{2}-d{3}$/, { message: 'Kod pocztowy musi mieć format XX-XXX' }),
19  country: z.string().min(1, { message: 'Kraj jest wymagany' })
20});
21
22// Schemat walidacji dla danych płatności
23const PaymentDetailsSchema = z.object({
24  cardholderName: z.string().min(1, { message: 'Imię i nazwisko na karcie jest wymagane' }),
25  cardNumber: z.string().regex(/^[0-9]{16}$/, { message: 'Numer karty musi mieć 16 cyfr' }),
26  expiryDate: z.string().regex(/^(0[1-9]|1[0-2])/[0-9]{2}$/, { message: 'Data ważności musi mieć format MM/RR' }),
27  cvv: z.string().regex(/^[0-9]{3}$/, { message: 'CVV musi składać się z 3 cyfr' })
28});
29
30// Funkcja walidująca dane osobowe
31export async function validatePersonalDetails(formData: FormData) {
32  // Ekstrakcja danych
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  // Walidacja
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  // Opcjonalnie: zapisz dane w sesji
51  // await saveToSession('personalDetails', result.data);
52  
53  return {
54    success: true,
55    data: result.data
56  };
57}
58
59// Funkcja walidująca adres dostawy
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// Funkcja walidująca dane płatności
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// Funkcja finalizująca zamówienie
108export async function submitOrder(personalDetails: any, shippingAddress: any, paymentDetails: any) {
109  try {
110    // Tu byłaby kod do przetwarzania płatności i zapisania zamówienia
111    console.log('Przetwarzanie zamówienia:', { personalDetails, shippingAddress, paymentDetails });
112    
113    // Symulacja opóźnienia
114    await new Promise(resolve => setTimeout(resolve, 1500));
115    
116    // Generowanie numeru zamówienia
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('Błąd podczas przetwarzania zamówienia:', error);
125    return {
126      success: false,
127      message: 'Wystąpił błąd podczas przetwarzania zamówienia. Spróbuj ponownie później.'
128    };
129  }
130}

I komponent klienta wykorzystujący te akcje:

1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { validatePersonalDetails, validateShippingAddress, validatePaymentDetails, submitOrder } from './actions';
6
7// Typy błędów dla każdego kroku
8type FormErrors = Record<string, string[]>;
9
10export default function CheckoutPage() {
11  // Stany do śledzenia kroku, danych i błędów
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  // Funkcja do obsługi kroku 1 - dane osobowe
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  // Funkcja do obsługi kroku 2 - adres dostawy
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  // Funkcja do obsługi kroku 3 - dane płatności
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      // Po walidacji wszystkich kroków, wysyłamy zamówienie
69      await finalizeOrder(result.data);
70    } else if (result.errors) {
71      setErrors(result.errors);
72    }
73  }
74  
75  // Finalizacja zamówienia
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); // Przejście do potwierdzenia
87    }
88  }
89  
90  // Powrót do poprzedniego kroku
91  function goToPreviousStep() {
92    setCurrentStep(prev => Math.max(1, prev - 1));
93    setErrors({});
94  }
95  
96  // Resetowanie formularza
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">Realizacja zamówienia</h1>
111      
112      {/* Pasek postępu */}
113      <div className="mb-6">
114        <div className="flex justify-between">
115          {['Dane osobowe', 'Adres dostawy', 'Płatność', 'Potwierdzenie'].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      {/* Krok 1: Dane osobowe */}
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">Imię</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">Nazwisko</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">Telefon</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 ? 'Przetwarzanie...' : 'Dalej'}
189          </button>
190        </form>
191      )}
192      
193      {/* Krok 2: Adres dostawy */}
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">Ulica i numer</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">Miasto</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">Kod pocztowy</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">Kraj</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="">Wybierz kraj</option>
241              <option value="Poland">Polska</option>
242              <option value="Germany">Niemcy</option>
243              <option value="UK">Wielka Brytania</option>
244              <option value="France">Francja</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              Wstecz
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 ? 'Przetwarzanie...' : 'Dalej'}
264            </button>
265          </div>
266        </form>
267      )}
268      
269      {/* Krok 3: Dane płatności */}
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">Imię i nazwisko na karcie</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">Numer karty</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">Data ważności</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              Wstecz
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 ? 'Przetwarzanie płatności...' : 'Złóż zamówienie'}
333            </button>
334          </div>
335        </form>
336      )}
337      
338      {/* Krok 4: Potwierdzenie */}
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">Zamówienie przyjęte!</h2>
348          <p className="text-gray-600 mb-1">Dziękujemy za złożenie zamówienia.</p>
349          <p className="text-gray-600 mb-4">Numer zamówienia: <span className="font-medium">{orderResult.orderNumber}</span></p>
350          
351          <p className="text-sm text-gray-500 mb-6">Szczegóły zamówienia wysłaliśmy na adres 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            Złóż nowe zamówienie
358          </button>
359        </div>
360      )}
361      
362      {/* Komunikat błędu finalizacji */}
363      {currentStep === 3 && orderResult?.success === false && (
364        <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
365          {orderResult.message || 'Wystąpił błąd podczas przetwarzania zamówienia.'}
366        </div>
367      )}
368    </div>
369  );
370}

Użycie useFormStatus i useFormState

Next.js udostępnia hooki

useFormStatus
i
useFormState
, które zapewniają lepszą integracje z Server Actions w formularzach:

1// app/contact/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schemat walidacji
7const ContactSchema = z.object({
8  name: z.string().min(1, { message: 'Imię jest wymagane' }),
9  email: z.string().email({ message: 'Nieprawidłowy adres email' }),
10  message: z.string().min(10, { message: 'Wiadomość musi mieć co najmniej 10 znaków' })
11});
12
13// Typ stanu formularza
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 z formatem zwrotnym dla useFormState
25export async function submitContactForm(prevState: FormState, formData: FormData): Promise<FormState> {
26  // Ekstrakcja danych
27  const name = formData.get('name');
28  const email = formData.get('email');
29  const message = formData.get('message');
30  
31  // Walidacja
32  const result = ContactSchema.safeParse({ name, email, message });
33  
34  if (!result.success) {
35    return {
36      errors: result.error.flatten().fieldErrors,
37      message: 'Popraw błędy w formularzu',
38      success: false
39    };
40  }
41  
42  try {
43    // Zapis wiadomości do bazy, wysyłka emaila itp.
44    await new Promise(resolve => setTimeout(resolve, 1000));
45    
46    // W rzeczywistej aplikacji, tu byłby kod do przetwarzania formularza
47    console.log('Wiadomość kontaktowa:', result.data);
48    
49    return {
50      errors: {},
51      message: 'Wiadomość została wysłana!',
52      success: true
53    };
54  } catch (error) {
55    return {
56      errors: {},
57      message: 'Wystąpił błąd. Spróbuj ponownie później.',
58      success: false
59    };
60  }
61}

I odpowiadający komponent formularza:

1// app/contact/page.tsx
2'use client';
3
4import { useFormState, useFormStatus } from 'react-dom';
5import { submitContactForm } from './actions';
6
7// Komponent przycisku z obsługą stanu formularza
8function SubmitButton() {
9  // useFormStatus daje dostęp do stanu formularza, w tym 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 ? 'Wysyłanie...' : 'Wyślij wiadomość'}
19    </button>
20  );
21}
22
23export default function ContactPage() {
24  // Inicjalizacja stanu formularza
25  const initialState = { errors: {}, message: '', success: false };
26  
27  // useFormState (łączy formularz z Server Action)
28  const [state, formAction] = useFormState(submitContactForm, initialState);
29  
30  // Resetowanie formularza po udanym wysłaniu
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">Kontakt</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            Imię i nazwisko
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            Wiadomość
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}

Kluczowe zalety tego podejścia:

  1. useFormStatus - pozwala na łatwe śledzenie stanu pending formularza
  2. useFormState - zapewnia automatyczne zarządzanie stanem między serwerem a klientem
  3. Progresywne ulepszanie - formularz działa nawet bez JavaScript

Walidacja z TypeScript i Zod

Zod doskonale integruje się z TypeScript, co pozwala na definiowanie schematów walidacji, które jednocześnie służą jako typy:

1// app/lib/validationSchemas.ts
2import { z } from 'zod';
3
4// Schemat produktu
5export const ProductSchema = z.object({
6  name: z.string().min(1, { message: 'Nazwa produktu jest wymagana' }),
7  price: z.coerce.number().min(0.01, { message: 'Cena musi być większa od 0' }),
8  description: z.string().min(10, { message: 'Opis musi mieć co najmniej 10 znaków' }),
9  category: z.enum(['electronics', 'clothing', 'food', 'books'], {
10    errorMap: () => ({ message: 'Wybierz poprawną kategorię' })
11  }),
12  quantity: z.coerce.number().int().min(0, { message: 'Ilość nie może być ujemna' }),
13  featured: z.boolean().default(false)
14});
15
16// Typ produktu inferowany ze schematu
17export type Product = z.infer<typeof ProductSchema>;
18
19// Schemat do edycji produktu (wszystkie pola opcjonalne)
20export const ProductUpdateSchema = ProductSchema.partial();
21export type ProductUpdate = z.infer<typeof ProductUpdateSchema>;

Użycie w 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// Funkcja dodająca nowy produkt
8export async function addProduct(formData: FormData) {
9  // Konwersja FormData do obiektu
10  const rawData = Object.fromEntries(formData.entries());
11  
12  // Konwersja pola 'featured' na boolean (checkbox)
13  const productData = {
14    ...rawData,
15    featured: formData.get('featured') === 'on'
16  };
17  
18  // Walidacja danych
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    // W rzeczywistej aplikacji: zapisanie produktu w bazie danych
30    // const newProduct = await db.product.create({ data: validationResult.data });
31    console.log('Dodano nowy produkt:', validationResult.data);
32    
33    // Rewalidacja ścieżki, aby odświeżyć cache
34    revalidatePath('/products');
35    
36    return {
37      success: true,
38      message: 'Produkt został dodany pomyślnie!'
39    };
40  } catch (error) {
41    console.error('Błąd podczas dodawania produktu:', error);
42    return {
43      success: false,
44      message: 'Wystąpił błąd podczas dodawania produktu.'
45    };
46  }
47}
48
49// Funkcja aktualizująca produkt (wszystkie pola opcjonalne)
50export async function updateProduct(id: string, formData: FormData) {
51  const rawData = Object.fromEntries(formData.entries());
52  
53  // Usuwamy puste pola, które nie powinny być aktualizowane
54  for (const [key, value] of Object.entries(rawData)) {
55    if (value === '') {
56      delete rawData[key];
57    }
58  }
59  
60  // Konwersja pola 'featured' na boolean (checkbox)
61  const productData = {
62    ...rawData,
63    featured: formData.has('featured')
64  };
65  
66  // Walidacja częściowej aktualizacji
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    // W rzeczywistej aplikacji: aktualizacja produktu w bazie danych
78    // const updatedProduct = await db.product.update({ 
79    //   where: { id },
80    //   data: validationResult.data 
81    // });
82    console.log(`Zaktualizowano produkt ${id}:`, validationResult.data);
83    
84    // Rewalidacja ścieżki, aby odświeżyć cache
85    revalidatePath(`/products/${id}`);
86    revalidatePath('/products');
87    
88    return {
89      success: true,
90      message: 'Produkt został zaktualizowany pomyślnie!'
91    };
92  } catch (error) {
93    console.error('Błąd podczas aktualizacji produktu:', error);
94    return {
95      success: false,
96      message: 'Wystąpił błąd podczas aktualizacji produktu.'
97    };
98  }
99}

Bezpieczeństwo w Server Actions

Podczas implementacji walidacji w Server Actions, należy pamiętać o kilku kluczowych kwestiach bezpieczeństwa:

1. Ochrona przed CSRF (Cross-Site Request Forgery)

Next.js automatycznie zabezpiecza Server Actions przed atakami CSRF za pomocą tzw. "Action ID" i innych mechanizmów. Jednakże, dobrze jest mieć świadomość tej ochrony i stosować dodatkowe zabezpieczenia w krytycznych operacjach.

2. Rate Limiting

Dla wrażliwych operacji (np. logowanie, rejestracja) warto zastosować ograniczenie liczby żądań z jednego adresu IP:

1'use server';
2
3import { cookies } from 'next/headers';
4
5// Prosta implementacja rate limitingu bazująca na cookies
6export async function loginUser(formData: FormData) {
7  const cookieStore = await cookies();
8  const attemptsCookie = cookieStore.get('login_attempts');
9  
10  // Sprawdzanie liczby prób logowania
11  const attempts = attemptsCookie ? parseInt(attemptsCookie.value, 10) : 0;
12  
13  if (attempts >= 5) {
14    return {
15      success: false,
16      message: 'Zbyt wiele prób logowania. Spróbuj ponownie później.'
17    };
18  }
19  
20  // Reszta logiki logowania...
21  
22  // W przypadku nieudanego logowania zwiększamy licznik
23  if (!success) {
24    (await cookies()).set('login_attempts', String(attempts + 1), {
25      maxAge: 60 * 15, // 15 minut
26      httpOnly: true,
27      secure: process.env.NODE_ENV === 'production',
28      sameSite: 'strict'
29    });
30  } else {
31    // Resetowanie licznika po udanym logowaniu
32    (await cookies()).set('login_attempts', '0');
33  }
34  
35  //
36}

3. Sanityzacja danych wejściowych

Zawsze czyść i waliduj dane wejściowe przed ich przetworzeniem:

1'use server';
2
3import { z } from 'zod';
4import { sanitizeHtml } from 'some-html-sanitizer';
5
6export async function addComment(formData: FormData) {
7  // Pobranie i wstępna sanityzacja
8  const rawComment = formData.get('comment')?.toString() || '';
9  const sanitizedComment = sanitizeHtml(rawComment, {
10    allowedTags: ['b', 'i', 'em', 'strong'],
11    allowedAttributes: {}
12  });
13  
14  // Walidacja po sanityzacji
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  // Zapis do bazy...
23}

4. Autoryzacja w Server Actions

Zawsze sprawdzaj uprawnienia użytkownika przed wykonaniem operacji:

1'use server';
2
3import { getServerSession } from 'next-auth';
4import { authOptions } from '../auth';
5
6export async function deleteProduct(productId: string) {
7  // Pobranie sesji użytkownika
8  const session = await getServerSession(authOptions);
9  
10  // Sprawdzenie czy użytkownik jest zalogowany
11  if (!session?.user) {
12    return {
13      success: false,
14      message: 'Musisz być zalogowany, aby wykonać tę operację.'
15    };
16  }
17  
18  // Sprawdzenie dodatkowych uprawnień (np. roli administratora)
19  if (session.user.role !== 'admin') {
20    return {
21      success: false,
22      message: 'Nie masz uprawnień do wykonania tej operacji.'
23    };
24  }
25  
26  // Wykonanie operacji...
27}

Dobre praktyki przy walidacji danych w Server Actions

  1. Kombinacja walidacji klient+serwer - waliduj po stronie klienta dla lepszego UX, ale zawsze weryfikuj na serwerze

  2. Spójne komunikaty błędów - używaj tych samych komunikatów po obu stronach

  3. Progresywne ulepszanie - upewnij się, że formularze działają nawet bez JavaScript

  4. Typowanie end-to-end - wykorzystaj inferowanie typów z Zod dla spójności między klientem a serwerem

  5. Rewalidacja danych - używaj

    revalidatePath
    lub
    revalidateTag
    po mutacjach, aby odświeżyć cache

  6. Struktura błędów - utrzymuj spójny format odpowiedzi dla błędów

  7. Minimalizacja stanu klienta - przechowuj jak najwięcej logiki na serwerze

  8. Uwzględniaj dostępność - używaj odpowiednich opisów błędów i atrybutów ARIA

Podsumowanie

Server Actions w Next.js zapewniają potężny mechanizm do implementacji walidacji po stronie serwera, który łączy zalety bezpieczeństwa z dobrym doświadczeniem użytkownika:

  • Bezpieczeństwo - dane są zawsze walidowane na serwerze, niezależnie od stanu JavaScript klienta
  • Wydajność - brak konieczności tworzenia osobnych API endpoints
  • Progresywne ulepszanie - formularze działają nawet bez JavaScript
  • Spójność typów - możliwość dzielenia schematów walidacji między klientem a serwerem

Stosując opisane w tym module techniki i dobre praktyki, możesz tworzyć solidne, bezpieczne i przyjazne dla użytkownika formularze, które wykorzystują pełnię możliwości Next.js.

Ir a CodeWorlds