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-actionfor typed, middleware-enabled actions.
Further reading: Next.js: Forms
Reference: https://nextjs.org/docs/app/guides/forms
More Next.js recipes
- Add a Content Security Policy with nonces in Next.js
- Add dark mode to Next.js without a flash
- Add SEO metadata, Open Graph and sitemaps
- Avoid data-fetching waterfalls in Server Components
- Build a validated JSON API with Route Handlers
- Create encrypted session cookies in Next.js
- Handle errors and 404s in the App Router
- Keep secrets and server code out of the client bundle