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.
Pomimo zalet walidacji po stronie klienta, walidacja serwerowa jest konieczna z kilku powodów:
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.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}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:
action bezpośrednio z Server ActionPrzykł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}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:
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}Podczas implementacji walidacji w Server Actions, należy pamiętać o kilku kluczowych kwestiach bezpieczeństwa:
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.
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}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}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}Kombinacja walidacji klient+serwer - waliduj po stronie klienta dla lepszego UX, ale zawsze weryfikuj na serwerze
Spójne komunikaty błędów - używaj tych samych komunikatów po obu stronach
Progresywne ulepszanie - upewnij się, że formularze działają nawet bez JavaScript
Typowanie end-to-end - wykorzystaj inferowanie typów z Zod dla spójności między klientem a serwerem
Rewalidacja danych - używaj
revalidatePath lub revalidateTag po mutacjach, aby odświeżyć cacheStruktura błędów - utrzymuj spójny format odpowiedzi dla błędów
Minimalizacja stanu klienta - przechowuj jak najwięcej logiki na serwerze
Uwzględniaj dostępność - używaj odpowiednich opisów błędów i atrybutów ARIA
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:
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.