A Next.js server action that throws on invalid input shows the user an error boundary instead of a message next to the field, and one that returns a Zod error object hits “Only plain objects can be passed to Client Components” — the result must be a small, serialisable shape designed for rendering.
This page builds that shape and wires it to useActionState in an App Router form: validation with a shared schema, field errors and submitted values in the result, a redirect on success, and the security rules that apply because a server action is a public endpoint. It extends hydration sync for SSR forms and uses the client pattern from form submission with React 19 useActionState.
Context and prerequisites
Facts about server actions that shape the design:
- They are POST endpoints. Anyone can call them with any payload; the
"use server"function’s arguments are untrusted input, regardless of what the form looked like. - Return values are serialised across the network with the React Server Components protocol. Plain objects, arrays, strings, numbers,
Dates and a few other types work; class instances (likeZodError) and functions do not. redirect()throws a special error that Next.js catches. Calling it insidetry/catchswallows the redirect unless you rethrow it.- Forms work before hydration. With
action={formAction}, a submission before the client bundle loads is a normal POST that renders the returned state on the server. - Uncontrolled inputs reset after a successful action (React 19 behaviour), so failed submissions must return the values to re-populate.
The core pattern: a result type, a server action, a client form
// app/contact/schema.ts — shared by client (instant checks) and server (authority)
import { z } from "zod";
export const ContactSchema = z.object({
name: z.string().trim().min(1, "Enter your name."),
email: z.string().trim().email("Enter an email like [email protected]."),
message: z.string().trim().min(20, "Tell us a bit more — at least 20 characters."),
});
export type ContactResult =
| { status: "idle" }
| { status: "error"; fieldErrors: Record<string, string[]>; formError?: string; values: Record<string, string> };
// app/contact/actions.ts
"use server";
import { redirect } from "next/navigation";
import { ContactSchema, type ContactResult } from "./schema";
export async function sendContact(_prev: ContactResult, formData: FormData): Promise<ContactResult> {
// Treat every field as untrusted: coerce to strings, ignore unexpected keys.
const values = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
message: String(formData.get("message") ?? ""),
};
const parsed = ContactSchema.safeParse(values);
if (!parsed.success) {
// flatten() → plain { fieldErrors: { name?: string[] … } }: serialisable.
return { status: "error", fieldErrors: parsed.error.flatten().fieldErrors, values };
}
try {
await deliver(parsed.data);
} catch {
return { status: "error", fieldErrors: {}, formError: "We couldn't send your message. Please try again.", values };
}
// Outside try/catch: redirect() works by throwing.
redirect("/contact/thanks");
}
declare function deliver(d: unknown): Promise<void>;
// app/contact/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendContact } from "./actions";
import type { ContactResult } from "./schema";
export function ContactForm() {
const [state, action, pending] = useActionState<ContactResult, FormData>(sendContact, { status: "idle" });
const err = state.status === "error" ? state.fieldErrors : {};
const v = state.status === "error" ? state.values : { name: "", email: "", message: "" };
return (
<form action={action} noValidate key={state.status === "error" ? JSON.stringify(v) : "fresh"}>
{state.status === "error" && state.formError && <p role="alert">{state.formError}</p>}
<label htmlFor="name">Name</label>
<input id="name" name="name" defaultValue={v.name} aria-invalid={err.name ? true : undefined}
aria-describedby={err.name ? "name-error" : undefined} autoComplete="name" />
{err.name && <p id="name-error">{err.name[0]}</p>}
{/* email and message follow the same pattern */}
<button type="submit" aria-disabled={pending}>{pending ? "Sending…" : "Send message"}</button>
</form>
);
}
Step-by-step walkthrough
- Define the result as a discriminated union of plain data. It is both the API contract of the action and the client’s render input.
- Parse untrusted
FormDataexplicitly. Read only the keys you expect, coerce to strings, and never spreadObject.fromEntries(formData)into a database call. - Validate with the shared schema and
flatten().flatten().fieldErrorsgivesRecord<string, string[]>— plain and serialisable — which the client maps onto fields. Sharing the schema is covered in sharing one Zod schema between client and server. - Return values on failure. Re-populate inputs with
defaultValue={v.name}; keying the form on the values ensures React re-applies them after the automatic reset. - Redirect outside
try/catch. Or rethrow withunstable_rethrow/ by checking for the redirect error, depending on your Next.js version. - Authorise inside the action. Check the session and permissions in the action itself; hiding the form from unauthorised users does not stop them calling the endpoint.
Why the action must not trust the form’s shape
It is tempting to think of a server action as a private function the form calls. It is not: Next.js exposes it as an endpoint with an identifier the page ships to the browser, and any client can invoke it with arbitrary arguments. Hidden fields can be edited, disabled fields can be sent anyway, and fields that do not exist in the form can be added. Everything the action needs to decide — who the user is, which record they may change, which fields they may set — must come from the server’s own session and data, with the form’s values treated as requests to be validated rather than facts. The schema enforces shape; authorisation enforces permission; both belong inside the action.
Failure modes and edge cases
1. Throwing for validation failures
throw new Error("Invalid email") renders the nearest error.tsx boundary and loses the form. Reserve throws for truly unexpected failures; return validation problems as data.
2. Returning the schema error object
return { error: parsed.error } fails serialisation or produces an unhelpful structure. Flatten or map issues to strings keyed by field.
3. Echoing secrets
Returning values for a password or card field puts it into the RSC payload and, before hydration, into server-rendered HTML. Exclude sensitive fields from values.
4. Focus after the action returns
After an error result, move focus to an error summary or the first invalid field in an effect keyed on the result, as described in moving focus to the first invalid field. Without it, the update happens silently for keyboard and screen-reader users.
5. Rate limiting and abuse
Because actions are public endpoints, contact and signup actions attract bots. Rate-limit by IP or session inside the action, and consider a challenge for anonymous forms; return a form-level error when limited.
Verification checklist
Frequently Asked Questions
Should I also validate on the client?
For instant feedback, yes — run the same schema on blur. But the action must validate regardless, because clients can skip their own checks. Client validation is a courtesy; server validation is the rule.
Can I use next-safe-action or similar libraries?
Yes. Libraries that wrap server actions with schema validation and typed results implement the same pattern — validated input, serialisable result, middleware for auth. Choose one if you have many actions; the principles on this page still apply to how you render the results.
How do I map server-only errors like "email already registered"?
Add them to fieldErrors under the field’s key after your database check, exactly like schema errors. The client does not need to know which ones came from the schema and which from the database.