INTERMEDIATEREACTSERVER ACTIONS

Server Action with Zod Validation and useActionState

A type-safe Next.js Server Action validated with Zod, wired to a form via useActionState for inline error rendering.

Published Apr 28, 2026
reactnextjsserver-actionszodforms

A Server Action that validates incoming form data with Zod and returns either a success or a structured error object. The form component uses useActionState to render field-level errors inline. This is the pattern I use for every form on this site.

Tested on Next.js 16, React 19, Zod 3.

When to Use This

  • Any form that submits to a Server Action (sign in, sign up, contact, settings)
  • When you want type-safe form input on both server and client
  • When you want field-level error rendering without an HTTP round-trip
  • When you need progressive enhancement (forms that work without JS)

Don't use this when the form is purely client-side (no server submission) or when you need optimistic UI updates with multiple steps (use a state machine instead).

Code

// app/contact/actions.ts
'use server';
 
import { z } from 'zod';
 
const ContactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});
 
export type ContactFormState = {
  ok: boolean;
  errors?: Record<string, string[]>;
  values?: { name: string; email: string; message: string };
};
 
export async function submitContactAction(
  _prev: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const parsed = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  });
 
  if (!parsed.success) {
    return {
      ok: false,
      errors: parsed.error.flatten().fieldErrors,
      values: {
        name: String(formData.get('name') ?? ''),
        email: String(formData.get('email') ?? ''),
        message: String(formData.get('message') ?? ''),
      },
    };
  }
 
  // ... save to database, send email, etc. ...
  await sendContactEmail(parsed.data);
 
  return { ok: true };
}

safeParse returns a result object instead of throwing — that's what makes the action serializable for useActionState. The _prev argument is the previous state from the hook, which you can use to implement multi-step flows or revert on failure.

Usage

The form component using useActionState:

'use client';
 
import { useActionState } from 'react';
import { submitContactAction, type ContactFormState } from './actions';
 
const initialState: ContactFormState = { ok: false };
 
export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    submitContactAction,
    initialState
  );
 
  if (state.ok) {
    return <p className="text-emerald-600">Thanks! I will get back to you.</p>;
  }
 
  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          defaultValue={state.values?.name}
          required
        />
        {state.errors?.name && (
          <p className="text-sm text-red-500">{state.errors.name[0]}</p>
        )}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={state.values?.email}
          required
        />
        {state.errors?.email && (
          <p className="text-sm text-red-500">{state.errors.email[0]}</p>
        )}
      </div>
      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          defaultValue={state.values?.message}
          required
        />
        {state.errors?.message && (
          <p className="text-sm text-red-500">{state.errors.message[0]}</p>
        )}
      </div>
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}

The defaultValue from state.values keeps the user's input around after a validation failure — without it, the form clears and the user has to retype everything.

Caveats

  • Don't return non-serializable objects from a Server Action. No Date, no Map, no class instances. Stick to JSON-compatible shapes.
  • useActionState requires the action's first parameter to be the previous state. Even if you don't use it, the signature is (prevState, formData) => newState. Old useFormState had the same signature.
  • Zod errors via flatten() are field-keyed and array-valued. Always render state.errors?.field?.[0], not state.errors?.field. Forgetting the [0] gives you ["error message"] rendered as a string.
  • The form keeps working without JavaScript. That's the whole point of Server Actions. Test it with JS disabled in DevTools at least once.
  • pending from useActionState is true while the action is in flight. Use it to disable the submit button or show a spinner — don't roll your own loading state.

Frequently Asked Questions

Why validate Server Action input with Zod instead of trusting FormData?

FormData is just strings — no types, no validation, no guarantees. Zod gives you a single source of truth for the shape and constraints of the input, validates at runtime, and gives you a typed object on success. This is the same pattern you'd use for an API route.

How does useActionState handle errors from a Server Action?

useActionState lets the Server Action return any serializable object, including a structured error. The hook provides the latest return value as state, so you can render field-level errors right next to inputs. Combined with progressive enhancement, the form still works without JavaScript.

X (Twitter)LinkedIn