Back to blog

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

July 24, 20262 min read
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

  1. A Zod schema defines what valid data looks like — once, shared between client-visible error messages and server-side validation.

  2. A Server Action reads FormData, validates it with the schema, and either returns field errors or performs the mutation.

  3. 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.