Next.js Server Actions with useActionState

How do you validate a form in the Next.js App Router so that it works before the client bundle hydrates, shows field-level errors next to the right inputs, keeps what the user typed, and still gives instant client-side feedback once React is running? This recipe writes a "use server" action that parses FormData with a shared Zod schema and returns a typed state object, wires it to the form with React’s useActionState, renders errors with aria-describedby, and layers the site’s canonical client check — setCustomValidity() plus reportValidity() from the Constraint Validation API — on top without replacing the server’s authority.

When to Use Server Actions for Validation

Server actions are the natural choice for forms in a Next.js App Router application whose only consumer is the page itself: sign-up, profile, settings, contact and checkout steps. They are a good fit when:

  • You want progressive enhancement for free — a <form action={serverAction}> posts natively before hydration.
  • Validation needs server data — uniqueness, permissions, stock — and you want the same code path for every rule.
  • You prefer typed end-to-end state over designing a separate JSON error contract.

If the same mutation must serve a mobile app or partners, put the rules behind an API and let the action call it; the action then maps the API’s validation error contract into its state. The general model is described in server actions and progressive enhancement.

useActionState round trip The form posts to the server action through useActionState; the action validates FormData with the shared schema and returns a state object with errors and values, which React renders next to the fields. Browser form useActionState Server action submit (FormData) signup(prevState, formData) signupSchema.safeParse + uniqueness check { errors, values } or redirect() re-render with errors and defaultValues
Before hydration the same action runs through a native post; after hydration React calls it without a page load, and the state object drives the UI in both cases.

Minimal Working Server Action and Form

// app/signup/schema.ts — shared with the client
import { z } from "zod";

export const signupSchema = z.object({
  email: z.string().trim().min(1, "Enter your email address.").email("Enter an email address like name@example.com."),
  password: z.string().min(12, "Password must be at least 12 characters."),
});

export type SignupField = keyof z.input<typeof signupSchema>;

export interface SignupState {
  errors: Partial<Record<SignupField, string>>;
  values: { email?: string };
  formError?: string;
}

export const initialSignupState: SignupState = { errors: {}, values: {} };
// app/signup/actions.ts
"use server";

import { redirect } from "next/navigation";
import { signupSchema, type SignupState, type SignupField } from "./schema";
import { users } from "@/server/users";

export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> {
  const values = { email: String(formData.get("email") ?? "") };   // echo back — never the password
  const parsed = signupSchema.safeParse(Object.fromEntries(formData));

  if (!parsed.success) {
    const errors: SignupState["errors"] = {};
    for (const issue of parsed.error.issues) errors[issue.path[0] as SignupField] ??= issue.message;
    return { errors, values };
  }
  if (await users.exists(parsed.data.email)) {
    return { errors: { email: "An account with this email already exists." }, values };
  }
  try {
    await users.create(parsed.data);
  } catch {
    return { errors: {}, values, formError: "We couldn't create your account. Please try again." };
  }
  redirect("/welcome");            // throws internally; must be outside try/catch
}
// app/signup/signup-form.tsx
"use client";

import { useActionState, useEffect, useRef } from "react";
import { signup } from "./actions";
import { initialSignupState, signupSchema } from "./schema";

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, initialSignupState);
  const formRef = useRef<HTMLFormElement>(null);

  // Mirror server errors into native validity so reportValidity() focuses and announces them.
  useEffect(() => {
    const form = formRef.current;
    if (!form) return;
    for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
      el.setCustomValidity(state.errors[el.name as keyof typeof state.errors] ?? "");
    }
    if (Object.keys(state.errors).length) form.reportValidity();
  }, [state]);

  // Client-side pre-check with the same schema: instant feedback, server still decides.
  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    const form = event.currentTarget;
    const result = signupSchema.safeParse(Object.fromEntries(new FormData(form)));
    for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
      const issue = result.success ? undefined : result.error.issues.find((i) => i.path[0] === el.name);
      el.setCustomValidity(issue?.message ?? "");
    }
    if (!form.reportValidity()) event.preventDefault();
  }

  return (
    <form ref={formRef} action={formAction} onSubmit={onSubmit} noValidate aria-busy={isPending}>
      {state.formError && <p role="alert" className="form-error">{state.formError}</p>}

      <label htmlFor="email">Email address</label>
      <input id="email" name="email" type="email" autoComplete="email" required
             defaultValue={state.values.email} aria-invalid={Boolean(state.errors.email)}
             aria-describedby={state.errors.email ? "email-err" : undefined}
             onInput={(e) => e.currentTarget.setCustomValidity("")} />
      {state.errors.email && <p id="email-err" className="field-error">{state.errors.email}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" autoComplete="new-password" required minLength={12}
             aria-invalid={Boolean(state.errors.password)}
             aria-describedby={state.errors.password ? "password-err" : undefined}
             onInput={(e) => e.currentTarget.setCustomValidity("")} />
      {state.errors.password && <p id="password-err" className="field-error">{state.errors.password}</p>}

      <button type="submit" aria-disabled={isPending}>{isPending ? "Creating account…" : "Create account"}</button>
    </form>
  );
}

The onSubmit handler runs before React dispatches the action: if the client-side check fails, preventDefault() stops the action from being called at all. If it passes, React calls the server action, which validates everything again. Before hydration, onSubmit does not exist yet and the browser posts natively — the action still runs and the page re-renders with the returned state.

Validation layers in a Next.js form The client-side schema check runs in onSubmit after hydration, the server action re-validates with the same schema plus server-only rules, and the returned state is mirrored into native validity for focus and announcement. onSubmit pre-check shared schema, reportValidity Server action shared schema again Server-only rules uniqueness, permissions Returned state errors + echoed values useEffect mirror setCustomValidity + focus
Two runs of one schema: the first for speed, the second for authority; native validity delivers both results the same way.

useActionState Option Reference

Item Type Purpose Notes
useActionState(action, initial) hook Returns [state, formAction, isPending] React 19 / Next.js 15; replaces useFormState
Action signature (prev, formData) => Promise<State> Receives the previous state first Keep state serialisable
state.errors Record<field, string> One message per field First issue per path
state.values Record<field, string> Echoed input Never include secrets
state.formError string Form-level failure Rendered in role="alert"
isPending boolean True while the action runs Drive aria-busy and button text
redirect() next/navigation Success navigation Throws; call outside try
defaultValue input prop Restores echoed values Do not use controlled value from state

Verification Steps

import { test, expect } from "@playwright/test";

test.describe("signup without JavaScript", () => {
  test.use({ javaScriptEnabled: false });
  test("server action returns errors and values", async ({ page }) => {
    await page.goto("/signup");
    await page.getByLabel("Email address").fill("taken@example.com");
    await page.getByLabel("Password").fill("correct horse battery staple");
    await page.getByRole("button", { name: "Create account" }).click();
    await expect(page.getByText("An account with this email already exists.")).toBeVisible();
    await expect(page.getByLabel("Email address")).toHaveValue("taken@example.com");
  });
});

Edge Cases and Failure Modes

redirect() inside try/catch. redirect() works by throwing a special error. Wrapping it in try/catch swallows the redirect and the user stays on the form. Call it after the try block.

Controlled inputs reset by state. Binding value={state.values.email} makes the input controlled by the last action result, so typing appears to do nothing until the next submission. Use defaultValue, and add a key to the form tied to a submission counter only if you truly need to reset it.

Server errors that never clear. The useEffect mirrors state.errors into custom validity, but state does not change while the user types. The onInput handler that clears custom validity is what lets the user resubmit after fixing a server-flagged field.

Leaking server internals through state. Everything returned from the action is serialised to the client. Never return raw exception messages, database errors or other users’ data in formError.

Reusing the Action From a Separate API

Some teams want both a server action for the web form and a REST endpoint for other clients. Keep the business logic in a plain server function that takes validated data and returns either success or a list of field errors, and call it from both the action and the route handler. The action adapts the result into SignupState; the route handler adapts it into a 422 problem document as described in problem details for field errors. Validation rules then live in exactly two places that are the same place: the shared schema and the core function’s server-only checks.

// server/create-account.ts — used by the action and by app/api/signup/route.ts
export async function createAccount(data: z.output<typeof signupSchema>): Promise<{ ok: true } | { ok: false; errors: Record<string, string> }> {
  if (await users.exists(data.email)) return { ok: false, errors: { email: "An account with this email already exists." } };
  await users.create(data);
  return { ok: true };
}
Server action state versus a client form library Two columns comparing validation with a server action and useActionState against a client-side form library such as React Hook Form in a Next.js app. Server action + useActionState ✓ works before hydration ✓ one code path for every rule • errors arrive after a round trip ✗ live per-keystroke UX needs extra code Client form library ✓ rich live validation and field state ✓ large ecosystem of inputs ✗ needs hydration before it works ✗ server must still re-validate
Server actions give authority and no-script support by default; client libraries give richer live feedback. Many forms use the action as the submit path and a light client check on top.

Accessibility Details Specific to React Rendering

React re-renders the error paragraphs when state changes, which interacts with assistive technology in two ways worth handling. First, conditionally rendered error elements must exist before the input references them: the example only sets aria-describedby when the error is present, which avoids pointing at a missing id. Second, the announcement comes from focus, not from the paragraph appearing — reportValidity() in the useEffect moves focus to the first failing field, and the screen reader reads its label and description, including the new error. Adding role="alert" to every field error as well would announce each one immediately and then again on focus; keep role="alert" for the single form-level message only. For forms with many fields, render an error summary above the form from state.errors, following building an accessible error summary, and focus it instead of the first field when there is more than one error.

Frequently Asked Questions

How do I validate a form with Next.js server actions?

Parse the FormData in a "use server" action with a schema such as Zod, return a state object containing field errors and echoed values, and read it in the form with React's useActionState hook.

Does a Next.js server action form work without JavaScript?

Yes. A form whose action prop is a server action posts natively before hydration; the action runs on the server and the page re-renders with the returned state.

How do I keep typed values after a server action returns errors?

Return the non-sensitive values in the action's state and render them with defaultValue. Do not bind value to the state, which makes inputs controlled and ignores typing.

Why does redirect() not work inside my server action?

redirect() throws to trigger navigation. If it is inside a try/catch block, the catch swallows it. Call redirect() after the try block has finished.

← Back to Server Actions and Progressive Enhancement