Form Handling in Next.js 15 Zod, Server Actions, and the

typescript dev.to

For a long time my forms were a mess of scattered useState calls, one per field, plus a manual validation function that duplicated whatever rules the backend already had.

Zod plus Server Actions fixed most of that. One schema, shared between client and server, and a lot less code overall. Here's the setup I use now.


1. One Schema, Used on Both Sides

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

export const ContactSchema = z.object({
  name: z.string().min(2, 'Name is too short').max(50),
  email: z.string().email('Enter a valid email'),
  message: z.string().min(10, 'Message is too short').max(1000),
});

export type ContactInput = z.infer<typeof ContactSchema>;
Enter fullscreen mode Exit fullscreen mode

This schema gets imported in the client component for instant feedback and in the Server Action for the real check. Two places using the same rules means the validation can never quietly drift out of sync.


2. Client-Side Validation with React Hook Form

// components/ContactForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ContactSchema, ContactInput } from '@/lib/validations/contact';
import { submitContact } from '@/actions/contact';
import { useState } from 'react';

export function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<ContactInput>({
    resolver: zodResolver(ContactSchema),
  });

  async function onSubmit(data: ContactInput) {
    const result = await submitContact(data);

    if (result.success) {
      setStatus('success');
      reset();
    } else {
      setStatus('error');
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <input {...register('name')} placeholder="Your name" />
        {errors.name && <p className="text-red-400 text-sm">{errors.name.message}</p>}
      </div>

      <div>
        <input {...register('email')} placeholder="Email" />
        {errors.email && <p className="text-red-400 text-sm">{errors.email.message}</p>}
      </div>

      <div>
        <textarea {...register('message')} placeholder="Message" />
        {errors.message && <p className="text-red-400 text-sm">{errors.message.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send message'}
      </button>

      {status === 'success' && <p className="text-green-400">Message sent.</p>}
      {status === 'error' && <p className="text-red-400">Something went wrong.</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

zodResolver connects the schema directly to React Hook Form, so field errors appear as the user types without writing a single manual validation function.


3. Re-Validating on the Server, Always

Client validation is for user experience. It is not security. Anyone can bypass it by calling the Server Action directly, so the server has to check again regardless of what the client already confirmed.

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

import { ContactSchema, ContactInput } from '@/lib/validations/contact';
import { connectDB } from '@/lib/db';
import Message from '@/models/Message';

export async function submitContact(input: ContactInput) {
  const parsed = ContactSchema.safeParse(input);

  if (!parsed.success) {
    return { success: false, message: 'Validation failed' };
  }

  await connectDB();
  await Message.create(parsed.data);

  return { success: true, message: 'Message sent' };
}
Enter fullscreen mode Exit fullscreen mode

Same schema, same rules, checked again on data that could have come from anywhere. This is the part people skip once client validation feels solid, and it is the part that actually matters for security.


4. Handling Progressive Enhancement with useActionState

For forms that should work even before JavaScript loads, or where you want built-in pending states without React Hook Form, useActionState pairs directly with a native form:

'use client';
import { useActionState } from 'react';
import { submitNewsletter } from '@/actions/newsletter';

const initialState = { success: false, message: '' };

export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(submitNewsletter, initialState);

  return (
    <form action={formAction}>
      <input name="email" type="email" placeholder="Enter email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Subscribing...' : 'Subscribe'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode
// actions/newsletter.ts
'use server';
import { z } from 'zod';

const EmailSchema = z.string().email();

export async function submitNewsletter(prevState: any, formData: FormData) {
  const email = formData.get('email');
  const parsed = EmailSchema.safeParse(email);

  if (!parsed.success) {
    return { success: false, message: 'Enter a valid email' };
  }

  await db.subscribers.create({ data: { email: parsed.data } });
  return { success: true, message: 'Subscribed' };
}
Enter fullscreen mode Exit fullscreen mode

Simpler forms, like a single email field, usually do not need React Hook Form at all. useActionState alone handles pending state and result feedback with less code.


5. Field-Level Errors from the Server

Sometimes the server catches something the client cannot, like a duplicate email. Return errors mapped to specific fields, not just a flat message:

// actions/users.ts
'use server';
import { CreateUserSchema } from '@/lib/validations/user';

export async function createUser(formData: FormData) {
  const parsed = CreateUserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  if (!parsed.success) {
    return {
      success: false,
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  const exists = await User.findOne({ email: parsed.data.email });
  if (exists) {
    return {
      success: false,
      errors: { email: ['This email is already registered'] },
    };
  }

  await User.create(parsed.data);
  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

parsed.error.flatten().fieldErrors gives you an object keyed by field name, which maps directly onto the same error display you already built for client-side validation. One error rendering path handles both sources.


6. Optimistic Updates for Fast-Feeling Forms

For simple mutations, like toggling a checkbox or deleting an item, waiting on a full round trip before updating the UI feels slow even when the request is fast.

'use client';
import { useOptimistic } from 'react';
import { toggleComplete } from '@/actions/todos';

export function TodoItem({ todo }) {
  const [optimisticTodo, setOptimisticTodo] = useOptimistic(todo);

  async function handleToggle() {
    setOptimisticTodo({ ...todo, completed: !todo.completed });
    await toggleComplete(todo.id);
  }

  return (
    <div onClick={handleToggle}>
      <span className={optimisticTodo.completed ? 'line-through' : ''}>
        {optimisticTodo.title}
      </span>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The UI updates instantly, then reconciles once the server actually confirms. If the server action fails, React reverts the optimistic state automatically. Worth reaching for on anything low-risk where waiting feels worse than briefly showing an update that might roll back.


Summary

Pattern Handles
Shared Zod schema Client and server validation staying in sync
React Hook Form + zodResolver Instant field-level feedback as the user types
Server-side safeParse Real validation on data that bypassed the client
useActionState Simple forms with built-in pending state, no extra library
fieldErrors from Zod Server-caught errors (like duplicates) mapped to the right field
useOptimistic Fast-feeling UI for low-risk mutations

The biggest shift for me was treating the schema as the source of truth, not the form library or the database. Everything else, client feedback, server validation, error display, just reads from that one definition.

I use this exact form setup, shared schema plus Server Action validation, across every project with a contact form, auth flow, or dashboard mutation.

See it in a real codebase:

Get the templates: https://pixelanas.gumroad.com

Do you validate on the client, the server, or both? Drop it below 👇


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Source: dev.to

arrow_back Back to Tutorials