Server Action Forms
Validate form data with Zod inside Server Actions and use useActionState to display errors and pending state.
Recipe
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.
Working Example
// 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:
- Server-side Zod validation in a Server Action
useActionStatefor state management and pending UI- Preserving non-sensitive form values across submissions
- Form-level errors separate from field-level errors
redirect()after successful mutation
Deep Dive
How It Works
- Server Actions are async functions marked with
"use server"that run on the server - When used with
<form action={formAction}>, the browser sends aFormDataPOST request useActionState(action, initialState)wraps the action, managing state updates and providingisPending- The action receives
(prevState, formData)and must return the same state shape - On validation failure, return errors; the component re-renders with the new state
- Forms work without JavaScript (progressive enhancement) — errors appear after a full page round-trip
Variations
Reusable 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 });
}TypeScript Notes
// 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);Gotchas
-
FormData.get()returnsstring | File | null— You need to cast or coerce. Fix: Useas stringfor text fields, or usez.coerce.*in your schema. -
redirect()throws internally — Do not wrapredirect()in a try/catch. Fix: Callredirect()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.
-
useActionStatevsuseFormStatus—useActionStatewraps the action and manages state.useFormStatusreads 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.
Alternatives
| 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 |
FAQs
What is the signature of a Server Action used with useActionState?
async function myAction(
prevState: State,
formData: FormData
): Promise<State> { ... }It receives the previous state and a FormData object, and returns the new state.
What three values does useActionState return?
state-- the current state object returned by the actionformAction-- the function to pass to<form action={...}>isPending-- a boolean that istruewhile the action is in flight
How do you display field-level validation errors from a Server Action?
Use 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>}Why should you preserve form values in the returned state?
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}.
How does progressive enhancement work with Server Action forms?
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.
How do you create a reusable validation helper for Server Actions?
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 };
}What is the difference between useActionState and useFormStatus?
useActionStatewraps the action, manages state, and providesisPendinguseFormStatusreads pending state from a parent<form>and must be used in a child component- They solve different problems and can be used together
Gotcha: What happens if you wrap redirect() in a try/catch?
redirect() throws internally to trigger navigation. Wrapping it in try/catch swallows the redirect. Always call redirect() outside of try/catch blocks.
Gotcha: Why should you never return password values in the action's state?
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.
How do you handle form-level errors separate from field-level errors?
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.
TypeScript: How do you type the state object shared between action and component?
export type State = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
// Both the action signature and useActionState use this typeTypeScript: What does FormData.get() return and how do you handle it?
- It returns
string | File | null - For text fields, cast with
as stringor usez.coerce.*in your schema - Never assume the value is a string without handling
null
Can you combine client-side and server-side validation?
Yes. 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.
Related
- Zod Basics — schema definition
- Form Patterns Basic — login, signup patterns
- Optimistic Forms — useOptimistic with forms
- Form Error Display — error display patterns