React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

server-actionszoduseActionStateformsnext.js

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
  • useActionState for 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 a FormData POST request
  • useActionState(action, initialState) wraps the action, managing state updates and providing isPending
  • 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() 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 useFormStatususeActionState 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.

Alternatives

AlternativeUse WhenDon't Use When
RHF + Zod (client only)You need instant field-level feedbackYou want progressive enhancement
Remix actionsYou are using Remix instead of Next.jsYou are on Next.js App Router
tRPC mutationsYou want end-to-end typed RPC without form actionsYou want native form progressive enhancement
API route + fetchYou need more control over the HTTP requestServer 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 action
  • formAction -- the function to pass to <form action={...}>
  • isPending -- a boolean that is true while 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?
  • useActionState wraps the action, manages state, and provides isPending
  • useFormStatus reads 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 type
TypeScript: What does FormData.get() return and how do you handle it?
  • It returns string | File | null
  • For text fields, cast with as string or use z.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.