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.
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.
useActionStaterequires the action's first parameter to be the previous state. Even if you don't use it, the signature is(prevState, formData) => newState. OlduseFormStatehad the same signature.- Zod errors via
flatten()are field-keyed and array-valued. Always renderstate.errors?.field?.[0], notstate.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.
pendingfromuseActionStateis 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.
Related Snippets & Reading
- useDebounce Hook for React — for debounced search inputs
- Auth-Gated proxy.ts in Next.js 16 — pairs with sign-in forms
- Zod docs — schema reference
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.