Skip to main content
Ganesh Joshi
Back to Blogs

Form handling with Zod and Server Actions in Next.js

February 15, 20264 min read
Tutorials
Form and validation code on screen

Next.js Server Actions receive form data and can validate it with Zod before doing any side effects. This gives you one place for validation logic, type-safe parsed data, and structured error messages for each field.

Why Zod with Server Actions?

Benefit Description
Type safety Validated data is fully typed
Single source of truth One schema for client and server
Structured errors Field-level error messages
Runtime validation Catches invalid data at runtime
Composable schemas Build complex validations from simple ones

Basic setup

Define the schema

// lib/schemas/contact.ts
import { z } from 'zod';

export const contactSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
  email: z.string().email('Please enter a valid email'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
  subscribe: z.coerce.boolean().default(false),
});

export type ContactFormData = z.infer<typeof contactSchema>;

Create the Server Action

// app/actions/contact.ts
'use server';

import { contactSchema } from '@/lib/schemas/contact';

type ActionResult = 
  | { success: true; message: string }
  | { success: false; errors: Record<string, string[]> };

export async function submitContact(formData: FormData): Promise<ActionResult> {
  const raw = Object.fromEntries(formData);
  
  const result = contactSchema.safeParse(raw);
  
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }
  
  const data = result.data;
  
  // Save to database, send email, etc.
  await saveContact(data);
  
  return {
    success: true,
    message: 'Thank you for your message!',
  };
}

Create the form component

// app/contact/page.tsx
'use client';

import { useActionState } from 'react';
import { submitContact } from '@/app/actions/contact';
import { Button, Input, Textarea } from '@/components/ui';

const initialState = { success: false, errors: {} };

export default function ContactPage() {
  const [state, formAction, pending] = useActionState(submitContact, initialState);

  if (state.success) {
    return <p className="text-green-500">{state.message}</p>;
  }

  return (
    <form action={formAction} className="space-y-4">
      <Input
        name="name"
        label="Name"
        error={state.errors?.name?.[0]}
      />
      
      <Input
        name="email"
        type="email"
        label="Email"
        error={state.errors?.email?.[0]}
      />
      
      <Textarea
        name="message"
        label="Message"
        rows={5}
        error={state.errors?.message?.[0]}
      />
      
      <label className="flex items-center gap-2">
        <input type="checkbox" name="subscribe" />
        Subscribe to newsletter
      </label>
      
      <Button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send Message'}
      </Button>
    </form>
  );
}

Advanced patterns

Nested objects

const addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  postalCode: z.string().regex(/^\d{5}$/),
});

const orderSchema = z.object({
  items: z.array(z.object({
    productId: z.string(),
    quantity: z.number().min(1),
  })).min(1, 'At least one item required'),
  shippingAddress: addressSchema,
  billingAddress: addressSchema.optional(),
});

Conditional validation

const paymentSchema = z.object({
  paymentMethod: z.enum(['card', 'bank', 'paypal']),
  cardNumber: z.string().optional(),
  bankAccount: z.string().optional(),
}).refine(
  (data) => {
    if (data.paymentMethod === 'card') {
      return data.cardNumber && data.cardNumber.length === 16;
    }
    return true;
  },
  {
    message: 'Card number must be 16 digits',
    path: ['cardNumber'],
  }
);

Transform and coerce

const schema = z.object({
  // Coerce string to number
  age: z.coerce.number().min(18),
  
  // Coerce checkbox to boolean
  terms: z.coerce.boolean(),
  
  // Transform to lowercase
  username: z.string().transform((val) => val.toLowerCase()),
  
  // Transform date string
  birthday: z.string().transform((val) => new Date(val)),
});

Client-side validation

Add client validation for instant feedback:

'use client';

import { useState } from 'react';
import { contactSchema } from '@/lib/schemas/contact';
import { submitContact } from '@/app/actions/contact';

export default function ContactForm() {
  const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
  const [serverResult, setServerResult] = useState(null);

  async function handleSubmit(formData: FormData) {
    // Clear previous errors
    setClientErrors({});
    
    // Client-side validation (optional, for UX)
    const raw = Object.fromEntries(formData);
    const clientResult = contactSchema.safeParse(raw);
    
    if (!clientResult.success) {
      const errors: Record<string, string> = {};
      for (const [key, messages] of Object.entries(clientResult.error.flatten().fieldErrors)) {
        errors[key] = messages?.[0] || 'Invalid';
      }
      setClientErrors(errors);
      return;
    }
    
    // Server validation (always required)
    const serverResult = await submitContact(formData);
    setServerResult(serverResult);
  }

  return (
    <form action={handleSubmit}>
      {/* Form fields with clientErrors displayed */}
    </form>
  );
}

Error message patterns

Field-level errors

// Return from Server Action
return {
  success: false,
  errors: {
    email: ['Email is required', 'Must be a valid email'],
    password: ['Password must be at least 8 characters'],
  },
};

Form-level errors

// For errors that span multiple fields
return {
  success: false,
  errors: {
    _form: ['Passwords do not match'],
  },
};

Display in form

{state.errors?._form && (
  <div className="bg-red-50 p-4 rounded-lg">
    {state.errors._form.map((error) => (
      <p key={error} className="text-red-600">{error}</p>
    ))}
  </div>
)}

Integration with React Hook Form

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/lib/schemas/contact';
import { submitContact } from '@/app/actions/contact';

export default function ContactForm() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
  });

  async function onSubmit(data: ContactFormData) {
    const formData = new FormData();
    Object.entries(data).forEach(([key, value]) => {
      formData.append(key, String(value));
    });
    
    const result = await submitContact(formData);
    
    if (!result.success && result.errors) {
      Object.entries(result.errors).forEach(([field, messages]) => {
        setError(field as keyof ContactFormData, {
          message: messages?.[0],
        });
      });
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}
      
      <input {...register('email')} type="email" />
      {errors.email && <span>{errors.email.message}</span>}
      
      <textarea {...register('message')} />
      {errors.message && <span>{errors.message.message}</span>}
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Summary

Form handling with Zod and Server Actions:

  1. Define schemas in shared files for type inference
  2. Use safeParse in Server Actions for validation
  3. Return structured errors with field names
  4. Display errors next to corresponding fields
  5. Add client validation for better UX (optional)
  6. Always validate server-side since clients can be bypassed

The Next.js Server Actions docs and Zod documentation have complete API references.

Frequently Asked Questions

Use Zod's safeParse in your Server Action to validate form data. Return structured errors for invalid data, or proceed with the validated data for valid submissions.

Always validate on the server since client validation can be bypassed. Add client validation for better UX, but never skip server validation.

Return an object with field names mapped to error messages. On the client, read the action result and display errors next to the corresponding fields.

Yes. Define schemas in a shared file and import them in both client and server code. This ensures consistent validation rules.

Use useActionState (React 19) or useFormState (React 18) to manage form state and action results. These hooks handle pending states and return values.

Related Posts