Form Validation with Next.js Server Actions: Zod, Rate Limiting and Spam Protection
Server Actions remove the need to write a separate API route for form handling. You define a function on the server and call it straight from the form. In practice there are a few details worth knowing — particularly around validation, spam protection and reading FormData.
The basic setup
A Server Action is declared with "use server" at the top of the file:
// app/contact/actions.ts
"use server";
export async function submitContactForm(formData: FormData) {
const name = formData.get("name");
const email = formData.get("email");
// ...
}This function is never bundled to the client; it runs only on the server. The client calls a reference and Next.js ships the request across.
Server-side validation is not optional
HTML validation (required, type="email") is a UX feature, not a security one. Everything from the client must be treated as untrusted — those checks can be bypassed with devtools or a direct HTTP request.
Defining a schema with Zod gives you validation and typing at once:
import { z } from "zod";
const contactSchema = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters").max(100),
email: z.string().trim().email("Enter a valid email address").max(254),
subject: z.string().trim().min(3).max(150),
message: z.string().trim().min(10, "Message must be at least 10 characters").max(5000),
});Don't omit the upper bounds. An unbounded text field strains your database and email provider, and leaves a resource-exhaustion vector open.
The FormData gotcha
Here's a behaviour that's easy to hit and hard to diagnose. React sometimes encodes form fields with a prefix — formData.get("email") returns null, because the actual key is something like _1_email.
The symptom: the form submits, validation reports "email is required", and the user definitely filled it in.
The fastest way to diagnose is logging the keys you received:
console.log([...formData.keys()]);The robust fix is matching on the suffix:
function getField(formData: FormData, name: string): FormDataEntryValue | null {
const exact = formData.get(name);
if (exact !== null) return exact;
for (const key of formData.keys()) {
if (key === name || key.endsWith(`_${name}`)) {
return formData.get(key);
}
}
return null;
}This works either way and removes the fragility entirely.
Honeypot bot filtering
Most bots fill every field in a form. Add one hidden via CSS and silently discard submissions that fill it:
<div aria-hidden="true" className="absolute left-[-9999px]">
<label htmlFor="website">Website</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" />
</div>const honeypot = getField(formData, "website");
if (typeof honeypot === "string" && honeypot.trim() !== "") {
// Bot caught; return success and drop it quietly
return { success: true };
}Two details matter:
How you hide it. Move it off-screen rather than using display: none — some bots are smart enough to skip display: none fields.
Accessibility. aria-hidden and tabIndex={-1} keep screen readers and keyboard users out of it. Without them, a screen reader user is asked to fill in an invisible field.
Returning success to a bot is deliberate: return an error and the bot learns it was caught, then changes tactics.
Rate limiting
Blocking repeated submissions from the same IP cuts both spam and cost:
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 3;
const attempts = new Map<string, number[]>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const recent = (attempts.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_REQUESTS) return true;
recent.push(now);
attempts.set(ip, recent);
return false;
}Getting the IP:
import { headers } from "next/headers";
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";The limitation of this in-memory approach: on serverless platforms each instance keeps its own map and instances restart. Fine for a small portfolio site; real traffic needs shared storage such as Redis.
Return a structured result
What the action returns determines how good your feedback can be. Return an object, not a boolean:
type ContactResult = {
success: boolean;
reason?: "validation" | "rate-limit" | "send-failed";
fieldErrors?: Record<string, string[]>;
};const parsed = contactSchema.safeParse({
name: getField(formData, "name"),
email: getField(formData, "email"),
subject: getField(formData, "subject"),
message: getField(formData, "message"),
});
if (!parsed.success) {
return {
success: false,
reason: "validation",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}Now the client can say exactly which field was rejected and why, instead of "something went wrong."
Catching third-party service failures
Some SDKs don't throw on failure — they return the error in the response object. A try/catch won't catch that:
const result = await emailClient.send({ ... });
// The error arrives in the return value; check for it
if (result.error) {
console.error("Email failed to send:", result.error);
return { success: false, reason: "send-failed" };
}Skipping this creates a silent failure where the user is told "your message was sent" and no email ever left. Always check your library's documented error behaviour.
The client side
useActionState (React 19) manages the pending state and result together:
"use client";
import { useActionState } from "react";
import { submitContactForm } from "./actions";
export function ContactForm() {
const [state, action, pending] = useActionState(submitContactForm, null);
return (
<form action={action}>
<input name="name" required minLength={2} />
{state?.fieldErrors?.name && (
<p role="alert">{state.fieldErrors.name[0]}</p>
)}
<textarea name="message" required minLength={10} />
<p id="message-hint">At least 10 characters.</p>
<button type="submit" disabled={pending}>
{pending ? "Sending..." : "Send"}
</button>
{state?.success && <p role="status">Your message has been sent.</p>}
</form>
);
}role="alert" and role="status" let screen readers announce the change. Signalling errors with colour alone makes the form inaccessible to users who can't see it.
Keep the client-side minLength identical to the server rule. If they differ, the user submits, the server rejects, and nothing explains why.
Checklist
Before a form ships:
- Is there server-side validation, or does it only trust HTML?
- Does every text field have an upper bound?
- Is the honeypot hidden accessibly?
- Is there rate limiting?
- Is the third-party service's error return checked?
- Do error messages identify the offending field?
- Is the button disabled while submitting?
A form that passes this list gives users clear feedback and keeps unnecessary spam and cost away from you.