· 6 min read
Type-Safe Forms in Next.js with Server Actions and Zod
- Next.js
- TypeScript
- Server Actions
- Zod
- Forms
Server Actions changed how I build forms in Next.js. Instead of shipping a client bundle that POSTs to an API route, the form calls a server function directly. It's validated end to end and works even before JavaScript loads. Here is the pattern I use in production, the same approach that powers the contact form on this site.
Why Server Actions over an API route
A traditional form needs three moving parts: a client handler, an API route, and a fetch call gluing them together. Each is a place for the types to drift apart. A Server Action collapses all three into one server function you can call directly from a form's action attribute.
- Progressive enhancement: the form submits without client JS.
- One source of truth: no request/response shape duplicated across client and server.
- The action runs on the server, so secrets (API keys, DB access) never touch the client.
Validate once, on the server, with Zod
The rule I follow: client-side validation is a UX convenience; the server-side check is the source of truth. Never trust the client. Define the schema once and infer the TypeScript type from it so the payload type can never disagree with the validation.
// app/actions.ts
'use server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().trim().min(2).max(120),
email: z.string().email(),
message: z.string().trim().min(10).max(5000),
});
// The payload type is DERIVED from the schema, so they can't drift.
type ContactPayload = z.infer<typeof contactSchema>;
export async function sendEmail(_prev: unknown, formData: FormData) {
const parsed = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
// parsed.data is fully typed as ContactPayload here.
await deliver(parsed.data);
return { success: true };
}Wire it up with useActionState
On the client, useActionState connects the action to the form and gives you the returned state (errors, success) plus a pending flag for the submit button, with no manual loading state.
'use client';
import { useActionState } from 'react';
import { sendEmail } from '@/app/actions';
export function ContactForm() {
const [state, formAction, isPending] = useActionState(sendEmail, {});
return (
<form action={formAction}>
<input name="name" />
<input name="email" type="email" />
<textarea name="message" />
{state.errors?.email && <p>{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
</form>
);
}Harden it before you ship
A public Server Action that sends email is an endpoint bots will find. Two cheap defenses stop the vast majority of abuse without a CAPTCHA:
- A honeypot field: a hidden input real users never fill. If it arrives non-empty, silently return success and send nothing.
- Rate limiting by IP: track submissions in a short rolling window and reject bursts. In-memory is fine for a single-region app; reach for Redis only at scale.
Takeaways
- Define the schema once with Zod and infer the type, so validation and types stay in lock-step.
- Validate on the server; treat client validation as UX only.
- Use useActionState for state + pending with zero boilerplate.
- Add a honeypot and rate limiting before exposing any form that triggers side effects.
This is the exact architecture behind the contact form here: a single typed Server Action, Zod-validated, progressively enhanced, and rate-limited. It's less code than the API-route approach and considerably harder to get wrong.