Server-side data validation is an inseparable element of security and data integrity in web applications. In the context of Next.js, Server Actions — introduced in Next.js 13 and enhanced in subsequent versions — offer a powerful mechanism for executing server-side logic directly from components. In this module, we will discuss how to effectively implement validation using Server Actions.
Despite the advantages of client-side validation, server-side validation is necessary for several reasons:
Server Actions are functions that run on the server but can be called directly from client-side code. You must mark them with the
'use server' directive — either at the top of the file (so that all functions in the file are Server Actions) or before a specific exported function declaration.1// app/actions.ts
2'use server'; // All functions exported from this file will be Server Actions
3
4import { z } from 'zod';
5
6// Validation schema
7const ContactSchema = z.object({
8 name: z.string().min(1, { message: 'Name is required' }),
9 email: z.string().email({ message: 'Invalid email address' }),
10 message: z.string().min(10, { message: 'Message must be at least 10 characters' })
11});
12
13export async function submitContactForm(formData: FormData) {
14 // Extract data from FormData
15 const name = formData.get('name');
16 const email = formData.get('email');
17 const message = formData.get('message');
18
19 // Validate data using Zod
20 const result = ContactSchema.safeParse({ name, email, message });
21
22 if (!result.success) {
23 // Return validation errors
24 return {
25 success: false,
26 errors: result.error.flatten().fieldErrors
27 };
28 }
29
30 // Process valid data
31 try {
32 // Here would be the code to save the message, send email, etc.
33 console.log('Valid form data:', result.data);
34
35 // Simulate database delay
36 await new Promise(resolve => setTimeout(resolve, 1000));
37
38 return {
39 success: true,
40 message: 'Message was sent!'
41 };
42 } catch (error) {
43 console.error('Error while processing the form:', error);
44 return {
45 success: false,
46 message: 'An error occurred while processing the form. Please try again later.'
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); // Alternatively can be displayed in the 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">Contact</h1>
41
42 {success && (
43 <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
44 Thank you for your message! We will respond as soon as 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:
action attribute directly with the Server ActionExample of validation in a multi-step form using Server Actions:
1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Validation schema for personal data
7const PersonalDetailsSchema = z.object({
8 firstName: z.string().min(1, { message: 'Name is required' }),
9 lastName: z.string().min(1, { message: 'Last name is required' }),
10 email: z.string().email({ message: 'Invalid email address' }),
11 phone: z.string().regex(/^[0-9]{9}$/, { message: 'Phone number must be 9 digits' })
12});
13
14// Validation schema for shipping address
15const ShippingAddressSchema = z.object({
16 street: z.string().min(1, { message: 'Street is required' }),
17 city: z.string().min(1, { message: 'City is required' }),
18 postalCode: z.string().regex(/^d{2}-d{3}$/, { message: 'Postal code must be in XX-XXX format' }),
19 country: z.string().min(1, { message: 'Country is required' })
20});
21
22// Validation schema for payment data
23const PaymentDetailsSchema = z.object({
24 cardholderName: z.string().min(1, { message: 'Name on card is required' }),
25 cardNumber: z.string().regex(/^[0-9]{16}$/, { message: 'Card number must be 16 digits' }),
26 expiryDate: z.string().regex(/^(0[1-9]|1[0-2])/[0-9]{2}$/, { message: 'Expiry date must be in MM/YY format' }),
27 cvv: z.string().regex(/^[0-9]{3}$/, { message: 'CVV must consist of 3 digits' })
28});
29
30// Function to validate personal data
31export async function validatePersonalDetails(formData: FormData) {
32 // Extract data
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 // Optionally: save data to session
51 // await saveToSession('personalDetails', result.data);
52
53 return {
54 success: true,
55 data: result.data
56 };
57}
58
59// Function to validate shipping address
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// Function to validate payment data
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// Function to finalize the order
108export async function submitOrder(personalDetails: any, shippingAddress: any, paymentDetails: any) {
109 try {
110 // Here would be the code for processing payment and saving the order
111 console.log('Processing order:', { personalDetails, shippingAddress, paymentDetails });
112
113 // Simulate delay
114 await new Promise(resolve => setTimeout(resolve, 1500));
115
116 // Generate order number
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('Error while processing the order:', error);
125 return {
126 success: false,
127 message: 'An error occurred while processing the order. Please try again later.'
128 };
129 }
130}And the client component using these actions:
1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { validatePersonalDetails, validateShippingAddress, validatePaymentDetails, submitOrder } from './actions';
6
7// Error types for each step
8type FormErrors = Record<string, string[]>;
9
10export default function CheckoutPage() {
11 // States for tracking step, data and errors
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 // Function to handle step 1 - personal data
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 // Function to handle step 2 - shipping address
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 // Function to handle step 3 - payment data
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 // After validating all steps, we submit the order
69 await finalizeOrder(result.data);
70 } else if (result.errors) {
71 setErrors(result.errors);
72 }
73 }
74
75 // Finalize order
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); // Move to confirmation
87 }
88 }
89
90 // Return to previous step
91 function goToPreviousStep() {
92 setCurrentStep(prev => Math.max(1, prev - 1));
93 setErrors({});
94 }
95
96 // Reset form
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">Order Processing</h1>
111
112 {/* Progress bar */}
113 <div className="mb-6">
114 <div className="flex justify-between">
115 {['Personal data', 'Shipping address', 'Payment', '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 {/* Step 1: Personal data */}
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">First name</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">Last name</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">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 ? 'Processing...' : 'Next'}
189 </button>
190 </form>
191 )}
192
193 {/* Step 2: Shipping address */}
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">Street and number</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">City</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">Postal code</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">Country</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="">Select country</option>
241 <option value="Poland">Poland</option>
242 <option value="Germany">Germany</option>
243 <option value="UK">United Kingdom</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 ? 'Processing...' : 'Next'}
264 </button>
265 </div>
266 </form>
267 )}
268
269 {/* Step 3: Payment data */}
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">Name on card</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">Card number</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">Expiry date</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 Back
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 ? 'Processing payment...' : 'Place order'}
333 </button>
334 </div>
335 </form>
336 )}
337
338 {/* Step 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">Order accepted!</h2>
348 <p className="text-gray-600 mb-1">Thank you for placing your order.</p>
349 <p className="text-gray-600 mb-4">Order number: <span className="font-medium">{orderResult.orderNumber}</span></p>
350
351 <p className="text-sm text-gray-500 mb-6">Order details have been sent to 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 Place new order
358 </button>
359 </div>
360 )}
361
362 {/* Finalization error message */}
363 {currentStep === 3 && orderResult?.success === false && (
364 <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
365 {orderResult.message || 'An error occurred while processing the order.'}
366 </div>
367 )}
368 </div>
369 );
370}Next.js provides the
useFormStatus and useFormState hooks, which provide better integration with Server Actions in forms:1// app/contact/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Validation schema
7const ContactSchema = z.object({
8 name: z.string().min(1, { message: 'Name is required' }),
9 email: z.string().email({ message: 'Invalid email address' }),
10 message: z.string().min(10, { message: 'Message must be at least 10 characters' })
11});
12
13// Form state type
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 with return format for useFormState
25export async function submitContactForm(prevState: FormState, formData: FormData): Promise<FormState> {
26 // Extract data
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: 'Fix the errors in the form',
38 success: false
39 };
40 }
41
42 try {
43 // Save message to database, send email, etc.
44 await new Promise(resolve => setTimeout(resolve, 1000));
45
46 // In a real application, here would be the form processing code
47 console.log('Contact message:', result.data);
48
49 return {
50 errors: {},
51 message: 'Message was sent!',
52 success: true
53 };
54 } catch (error) {
55 return {
56 errors: {},
57 message: 'An error occurred. Please try again later.',
58 success: false
59 };
60 }
61}And the corresponding form component:
1// app/contact/page.tsx
2'use client';
3
4import { useFormState, useFormStatus } from 'react-dom';
5import { submitContactForm } from './actions';
6
7// Button component with form state handling
8function SubmitButton() {
9 // useFormStatus gives access to form state, including 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 ? 'Sending...' : 'Send message'}
19 </button>
20 );
21}
22
23export default function ContactPage() {
24 // Initialize form state
25 const initialState = { errors: {}, message: '', success: false };
26
27 // useFormState (connects form with Server Action)
28 const [state, formAction] = useFormState(submitContactForm, initialState);
29
30 // Reset form 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}Key advantages of this approach:
Zod integrates excellently with TypeScript, allowing you to define validation schemas that simultaneously serve as types:
1// app/lib/validationSchemas.ts
2import { z } from 'zod';
3
4// Product schema
5export const ProductSchema = z.object({
6 name: z.string().min(1, { message: 'Product name is required' }),
7 price: z.coerce.number().min(0.01, { message: 'Price must be greater than 0' }),
8 description: z.string().min(10, { message: 'Description must be at least 10 characters' }),
9 category: z.enum(['electronics', 'clothing', 'food', 'books'], {
10 errorMap: () => ({ message: 'Select a valid category' })
11 }),
12 quantity: z.coerce.number().int().min(0, { message: 'Quantity cannot be negative' }),
13 featured: z.boolean().default(false)
14});
15
16// Product type inferred from schema
17export type Product = z.infer<typeof ProductSchema>;
18
19// Schema for editing product (all fields optional)
20export const ProductUpdateSchema = ProductSchema.partial();
21export type ProductUpdate = z.infer<typeof ProductUpdateSchema>;Usage in a 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// Function to add a new product
8export async function addProduct(formData: FormData) {
9 // Convert FormData to object
10 const rawData = Object.fromEntries(formData.entries());
11
12 // Convert 'featured' field to boolean (checkbox)
13 const productData = {
14 ...rawData,
15 featured: formData.get('featured') === 'on'
16 };
17
18 // Validate data
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 // In a real application: save product to database
30 // const newProduct = await db.product.create({ data: validationResult.data });
31 console.log('Added new product:', validationResult.data);
32
33 // Revalidate path to refresh cache
34 revalidatePath('/products');
35
36 return {
37 success: true,
38 message: 'Product was added successfully!'
39 };
40 } catch (error) {
41 console.error('Error while adding product:', error);
42 return {
43 success: false,
44 message: 'An error occurred while adding the product.'
45 };
46 }
47}
48
49// Function to update product (all fields optional)
50export async function updateProduct(id: string, formData: FormData) {
51 const rawData = Object.fromEntries(formData.entries());
52
53 // Remove empty fields that should not be updated
54 for (const [key, value] of Object.entries(rawData)) {
55 if (value === '') {
56 delete rawData[key];
57 }
58 }
59
60 // Convert 'featured' field to boolean (checkbox)
61 const productData = {
62 ...rawData,
63 featured: formData.has('featured')
64 };
65
66 // Validate partial update
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 // In a real application: update product in database
78 // const updatedProduct = await db.product.update({
79 // where: { id },
80 // data: validationResult.data
81 // });
82 console.log(`Updated product ${id}:`, validationResult.data);
83
84 // Revalidate path to refresh cache
85 revalidatePath(`/products/${id}`);
86 revalidatePath('/products');
87
88 return {
89 success: true,
90 message: 'Product was updated successfully!'
91 };
92 } catch (error) {
93 console.error('Error while updating product:', error);
94 return {
95 success: false,
96 message: 'An error occurred while updating the product.'
97 };
98 }
99}When implementing validation in Server Actions, several key security considerations must be kept in mind:
Next.js automatically protects Server Actions against CSRF attacks via so-called "Action IDs" and other mechanisms. However, it's good to be aware of this protection and apply additional safeguards in critical operations.
For sensitive operations (e.g. login, registration) it's worth applying a limit on the number of requests from a single IP address:
1'use server';
2
3import { cookies } from 'next/headers';
4
5// Simple cookie-based rate limiting implementation
6export async function loginUser(formData: FormData) {
7 const cookieStore = await cookies();
8 const attemptsCookie = cookieStore.get('login_attempts');
9
10 // Check number of login attempts
11 const attempts = attemptsCookie ? parseInt(attemptsCookie.value, 10) : 0;
12
13 if (attempts >= 5) {
14 return {
15 success: false,
16 message: 'Too many login attempts. Please try again later.'
17 };
18 }
19
20 // Rest of login logic...
21
22 // On failed login, increment the counter
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 // Reset counter after successful login
32 (await cookies()).set('login_attempts', '0');
33 }
34
35 //
36}Always sanitize and validate input data before processing:
1'use server';
2
3import { z } from 'zod';
4import { sanitizeHtml } from 'some-html-sanitizer';
5
6export async function addComment(formData: FormData) {
7 // Retrieve and initial sanitization
8 const rawComment = formData.get('comment')?.toString() || '';
9 const sanitizedComment = sanitizeHtml(rawComment, {
10 allowedTags: ['b', 'i', 'em', 'strong'],
11 allowedAttributes: {}
12 });
13
14 // Validation after sanitization
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 // Save to database...
23}Always check user permissions before executing an operation:
1'use server';
2
3import { getServerSession } from 'next-auth';
4import { authOptions } from '../auth';
5
6export async function deleteProduct(productId: string) {
7 // Get user session
8 const session = await getServerSession(authOptions);
9
10 // Check if user is logged in
11 if (!session?.user) {
12 return {
13 success: false,
14 message: 'You must be logged in to perform this operation.'
15 };
16 }
17
18 // Check additional permissions (e.g. admin role)
19 if (session.user.role !== 'admin') {
20 return {
21 success: false,
22 message: 'You do not have permission to perform this operation.'
23 };
24 }
25
26 // Execute operation...
27}Client + server validation combination - validate on the client for better UX, but always verify on the server
Consistent error messages - use the same messages on both sides
Progressive enhancement - ensure forms work even without JavaScript
End-to-end typing - use Zod type inference for consistency between client and server
Data revalidation - use
revalidatePath or revalidateTag after mutations to refresh the cacheError structure - maintain a consistent response format for errors
Minimize client state - keep as much logic as possible on the server
Consider accessibility - use appropriate error descriptions and ARIA attributes
Server Actions in Next.js provide a powerful mechanism for implementing server-side validation that combines security benefits with a good user experience:
By applying the techniques and best practices described in this module, you can build robust, secure, and user-friendly forms that take full advantage of Next.js capabilities.