Type-Safe Forms in Next.js: Server Actions + Zod + useActionState

This exact pattern — Server Action + Zod schema + useActionState — is what powers every admin form on this site. It's worth writing down properly, because it replaces a surprising amount of what used to require a form library, an API route, and a client-side validation library, all wired together by hand.
The shape of it
A Zod schema defines what valid data looks like — once, shared between client-visible error messages and server-side validation.
A Server Action reads FormData, validates it with the schema, and either returns field errors or performs the mutation.
useActionState wires a form to that action without any client-side fetch/JSON plumbing, and works even with JavaScript disabled (progressive enhancement) since it's a real <form action>.
1. The schema
// lib/validations/project.ts
import { z } from "zod";
export const projectSchema = z.object({
slug: z.string().min(2).regex(/^[a-z0-9-]+$/, "Slug must be kebab-case"),
titleEn: z.string().min(1, "English title is required"),
order: z.coerce.number().int().default(0), // FormData values are always strings — coerce them
});2. The Server Action
"use server";
export type ActionState = {
success: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createProject(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = projectSchema.safeParse({
slug: formData.get("slug"),
titleEn: formData.get("titleEn"),
order: formData.get("order") ?? 0,
});
if (!parsed.success) {
return {
success: false,
message: "Please fix the highlighted fields.",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
await projectRepository.create(parsed.data);
return { success: true };
}3. Wiring it to the form
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
const INITIAL_STATE: ActionState = { success: false };
function SubmitButton() {
const { pending } = useFormStatus(); // reads the nearest parent <form>'s pending state
return <button disabled={pending}>{pending ? "Saving…" : "Create"}</button>;
}
export default function ProjectForm() {
const [state, formAction] = useActionState(createProject, INITIAL_STATE);
return (
<form action={formAction}>
<input name="titleEn" />
{state.fieldErrors?.titleEn && <p>{state.fieldErrors.titleEn[0]}</p>}
<SubmitButton />
</form>
);
}A gotcha worth knowing: redirects
It's tempting to call redirect() at the end of a Server Action driven by useActionState — but this is currently unreliable in Next.js (the first submit can silently fail to navigate). The more reliable pattern: return { success: true } from the action, and redirect client-side from a useEffect watching that state.
useEffect(() => {
if (state.success) {
router.push("/admin/projects");
router.refresh(); // re-fetch server data for the destination page
}
}, [state.success, router]);Why this beats a client-side form library here
One schema, not two — no separate client validation config that can drift from the server's rules.
Works before hydration finishes, and degrades to a real form POST without JavaScript at all.
No API route, no fetch, no manual JSON (de)serialization, no loading-state plumbing beyond useFormStatus.
This isn't a replacement for something like React Hook Form on genuinely complex, highly interactive forms (multi-step wizards with heavy client-side interdependence) — but for the very common case of "create/edit this record," it's less code, fewer moving parts, and one source of truth for validation.
More posts

Authentication with better-auth in Next.js: A First-Hand Setup
The exact setup behind this site's own admin login: server config, the Next.js catch-all route handler, the client, and the two-layer middleware + layout protection that actually secures /admin.

Recharts in Next.js + Tailwind: Theming and Custom Styling
Making Recharts feel native to a Tailwind design system: correct Client Component boundaries, wiring colors to CSS variables for automatic dark mode, custom tooltips/legends, and handling RTL locales.