Next.js cookbook · Intermediate

Handle forms with Server Actions and validation

Create a contact form that validates on the server, shows field errors, and works without client-side JavaScript.

Approach

Write a Server Action ("use server") that validates FormData with Zod and returns field errors; wire it to the form with React's useActionState for pending state and errors. Treat every Server Action like a public API endpoint: authenticate, authorize and validate inside it.

Solution

// app/contact/actions.ts
"use server";
import { z } from "zod";

const Contact = z.object({
  email: z.string().email("Enter a valid email"),
  message: z.string().min(10, "Tell us a bit more").max(2000),
});
export type ContactState = { ok?: boolean; errors?: Partial<Record<"email" | "message", string[]>> };

export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
  const parsed = Contact.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };
  await rateLimitOrThrow(`contact:${(await headers()).get("x-forwarded-for") ?? "unknown"}`);
  await db.contactMessage.create({ data: parsed.data });
  return { ok: true };
}

// app/contact/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";

export function ContactForm() {
  const [state, action, pending] = useActionState<ContactState, FormData>(sendContact, {});
  if (state.ok) return <p role="status">Thanks! We'll be in touch.</p>;
  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" aria-describedby="email-error" />
      {state.errors?.email && <p id="email-error" role="alert">{state.errors.email[0]}</p>}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" aria-describedby="message-error" />
      {state.errors?.message && <p id="message-error" role="alert">{state.errors.message[0]}</p>}
      <button disabled={pending}>{pending ? "Sending…" : "Send"}</button>
    </form>
  );
}

Pitfalls

  • Assuming Server Actions are private — they're callable via POST by anyone.
  • Returning raw database errors to the client.
  • Passing secrets or full records back in the action result.

Variations

  • React Hook Form on the client plus the same Zod schema on the server.
  • next-safe-action for typed, middleware-enabled actions.

Further reading: Next.js: Forms

Reference: https://nextjs.org/docs/app/guides/forms

More Next.js recipes