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.
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.
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 };
}
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.
Related Guides
- Server Actions and Progressive Enhancement — the cross-framework model.
- React Hook Form Validation — when a client-side form library fits better.
- Integrating Zod Resolver with React Hook Form — the same schema in a client library.
- Validating FormData on the Server with Zod — arrays, checkboxes and files in actions.