Server Action Forms
Validate form data with Zod inside Server Actions and use useActionState to display errors and pending state.
Search across all documentation pages
Validate form data with Zod inside Server Actions and use useActionState to display errors and pending state.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// app/actions/contact.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
message: z.string().min(10, "At least 10 characters"),
});
export type ContactState = {
errors?: Record<string, string[]>;
message?: string;
success?: boolean;
};
export async function submitContact(
prevState: ContactState,
formData: FormData
): Promise<ContactState> {
const raw = Object.fromEntries(formData);
const result = ContactSchema.safeParse(raw);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors as Record<string, string[]> };
}
// Process the validated data
await saveToDatabase(result.data);
return { success: true, message: "Message sent!" };
}// app/contact/page.tsx
"use client";
import { useActionState } from "react";
import { submitContact, type ContactState } from "@/app/actions/contact";
export default function ContactPage() {
const [state, formAction, isPending] = useActionState(submitContact, {});
return (
<form action={formAction}>
<input name="name" />
{state.errors?.name && <p>{state.errors.name[0]}</p>}
<input name="email" />
{state.errors?.email && <p>{state.errors.email[0]}</p>}
<textarea name="message" />
{state.errors?.message && <p>{state.errors.message[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state.success && <p>{state.message}</p>}
</form>
);
}When to reach for this: When you want server-side validation without a client-side form library - progressive enhancement, simpler bundle, and works without JavaScript.
// app/actions/signup.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const SignupSchema = z
.object({
username: z.string().min(3).max(20).regex(/^[a-z0-9_]+$/),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignupState = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
export async function signup(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const raw = {
username: formData.get("username") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
};
const result = SignupSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
values: { username: raw.username, email: raw.email }, // preserve non-sensitive fields
};
}
// Check if user exists
const existing = await db.user.findUnique({ where: { email: result.data.email } });
if (existing) {
return {
formError: "An account with this email already exists",
values: { username: raw.username, email: raw.email },
};
}
await db.user.create({ data: result.data });
redirect("/dashboard");
}// app/signup/page.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "@/app/actions/signup";
export default function SignupPage() {
const [state, formAction, isPending] = useActionState(signup, {});
return (
<form action={formAction} className="max-w-md space-y-4">
{state.formError && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{state.formError}</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium">Username</label>
<input
id="username"
name="username"
defaultValue={state.values?.username ?? ""}
className="w-full rounded border p-2"
/>
{state.errors?.username && (
<p className="mt-1 text-sm text-red-600">{state.errors.username[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.values?.email ?? ""}
className="w-full rounded border p-2"
/>
{state.errors?.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">Password</label>
<input id="password" name="password" type="password" className="w-full rounded border p-2" />
{state.errors?.password && (
<p className="mt-1 text-sm text-red-600">{state.errors.password[0]}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">Confirm Password</label>
<input id="confirmPassword" name="confirmPassword" type="password" className="w-full rounded border p-2" />
{state.errors?.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{state.errors.confirmPassword[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Creating account..." : "Sign Up"}
</button>
</form>
);
}What this demonstrates:
useActionState for state management and pending UIredirect() after successful mutation"use server" that run on the server<form action={formAction}>, the browser sends a FormData POST requestuseActionState(action, initialState) wraps the action, managing state updates and providing isPending(prevState, formData) and must return the same state shapeReusable validation helper:
// lib/validate.ts
import { z } from "zod";
export function validateFormData<T extends z.ZodType>(
schema: T,
formData: FormData
): { success: true; data: z.infer<T> } | { success: false; errors: Record<string, string[]> } {
const raw = Object.fromEntries(formData);
const result = schema.safeParse(raw);
if (result.success) return { success: true, data: result.data };
return { success: false, errors: result.error.flatten().fieldErrors as Record<string, string[]> };
}
// Usage in action
export async function myAction(prev: State, formData: FormData) {
const v = validateFormData(MySchema, formData);
if (!v.success) return { errors: v.errors };
// v.data is typed
}Combining client and server validation:
// Client: immediate feedback
const { register, handleSubmit } = useForm({ resolver: zodResolver(Schema) });
// Server: authoritative validation
async function onSubmit(data: FormData) {
const result = await serverAction(data);
if (result.errors) setError("root", { message: result.formError });
}// The state type must match between action signature and useActionState
type State = { errors?: Record<string, string[]>; message?: string };
// Action signature for useActionState
export async function myAction(prev: State, formData: FormData): Promise<State> { ... }
// useActionState returns [State, (formData: FormData) => void, boolean]
const [state, action, isPending] = useActionState(myAction, {} as State);FormData.get() returns string | File | null - You need to cast or coerce. Fix: Use as string for text fields, or use z.coerce.* in your schema.
redirect() throws internally - Do not wrap redirect() in a try/catch. Fix: Call redirect() outside of try/catch blocks, or re-throw redirect errors.
Passwords in state - Never return password values in the state object. Fix: Only persist non-sensitive field values for repopulating the form.
useActionState vs useFormStatus - useActionState wraps the action and manages state. useFormStatus reads pending state from a parent <form> and must be in a child component. They solve different problems.
No real-time validation - Server Action forms only validate on submit. Fix: Add client-side validation with RHF + Zod for instant feedback, and keep server validation as the authority.
| Alternative | Use When | Don't Use When |
|---|---|---|
| RHF + Zod (client only) | You need instant field-level feedback | You want progressive enhancement |
| Remix actions | You are using Remix instead of Next.js | You are on Next.js App Router |
| tRPC mutations | You want end-to-end typed RPC without form actions | You want native form progressive enhancement |
| API route + fetch | You need more control over the HTTP request | Server Actions are simpler for form submissions |
async function myAction(
prevState: State,
formData: FormData
): Promise<State> { ... }It receives the previous state and a FormData object, and returns the new state.
state -- the current state object returned by the actionformAction -- the function to pass to <form action={...}>isPending -- a boolean that is true while the action is in flightUse result.error.flatten().fieldErrors from Zod's safeParse, return the errors in state, then render them per-field:
{state.errors?.email && <p>{state.errors.email[0]}</p>}After a server round-trip the form re-renders with empty fields. Returning non-sensitive values (e.g., username, email) in state lets you repopulate inputs via defaultValue={state.values?.fieldName}.
The form uses a native <form action={...}> POST. If JavaScript is disabled, the browser still submits the form and the server returns HTML with errors. With JS enabled, useActionState intercepts the submission for a seamless SPA experience.
function validateFormData<T extends z.ZodType>(
schema: T, formData: FormData
) {
const raw = Object.fromEntries(formData);
const result = schema.safeParse(raw);
if (result.success) return { success: true, data: result.data };
return { success: false, errors: result.error.flatten().fieldErrors };
}useActionState wraps the action, manages state, and provides isPendinguseFormStatus reads pending state from a parent <form> and must be used in a child componentredirect() throws internally to trigger navigation. Wrapping it in try/catch swallows the redirect. Always call redirect() outside of try/catch blocks.
The state is serialized and sent to the client. Returning sensitive values like passwords exposes them in the response payload. Only persist non-sensitive fields for repopulating the form.
Add a formError field to your state type for errors that are not tied to a specific field (e.g., "An account with this email already exists"), and render it above the form fields.
export type State = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
// Both the action signature and useActionState use this typestring | File | nullas string or use z.coerce.* in your schemanullYes. Use RHF + Zod on the client for instant feedback, then re-validate with the same schema in the Server Action as the authoritative check. The server validation is the source of truth.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥