We use cookies to enhance your experience on the site
CodeWorlds

Server-side Validation with Server Actions

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.

Why Do We Need Server-side Validation?

Despite the advantages of client-side validation, server-side validation is necessary for several reasons:

  1. Security - JavaScript code on the client side can be modified or bypassed
  2. Data consistency - the server is the final control layer before database modifications
  3. Complex business rule validation - some rules may require access to server-side data
  4. Progressive enhancement - ensuring functionality even when JavaScript is disabled

Server Actions Basics in Next.js

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.

Basic Server Action Structure

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}

Using Server Action in a Component

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:

  1. The form uses the
    action
    attribute directly with the Server Action
  2. Validation errors are returned to the component and displayed to the user
  3. The form can be reset after successful submission

Advanced Validation with Multi-step Forms

Example 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}

Using useFormStatus and useFormState

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:

  1. useFormStatus - allows easy tracking of the form's pending state
  2. useFormState - provides automatic state management between server and client
  3. Progressive enhancement - the form works even without JavaScript

Validation with TypeScript and Zod

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}

Security in Server Actions

When implementing validation in Server Actions, several key security considerations must be kept in mind:

1. CSRF (Cross-Site Request Forgery) Protection

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.

2. Rate Limiting

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}

3. Input Data Sanitization

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}

4. Authorization in Server Actions

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}

Best Practices for Data Validation in Server Actions

  1. Client + server validation combination - validate on the client for better UX, but always verify on the server

  2. Consistent error messages - use the same messages on both sides

  3. Progressive enhancement - ensure forms work even without JavaScript

  4. End-to-end typing - use Zod type inference for consistency between client and server

  5. Data revalidation - use

    revalidatePath
    or
    revalidateTag
    after mutations to refresh the cache

  6. Error structure - maintain a consistent response format for errors

  7. Minimize client state - keep as much logic as possible on the server

  8. Consider accessibility - use appropriate error descriptions and ARIA attributes

Summary

Server Actions in Next.js provide a powerful mechanism for implementing server-side validation that combines security benefits with a good user experience:

  • Security - data is always validated on the server, regardless of the client's JavaScript state
  • Performance - no need to create separate API endpoints
  • Progressive enhancement - forms work even without JavaScript
  • Type consistency - the ability to share validation schemas between client and server

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.

Go to CodeWorlds