A lead form in Next.js 16 needs no separate API route: the <form> calls a Server Action that validates the data with a Zod schema on the server and returns field errors as a plain value, while useActionState displays them and exposes a pending flag to disable the button. Spam and duplicate protection live on the server too: a hidden trap field (honeypot), a per-IP rate limit and a repeated submit that never creates a second lead. If the CRM fails, the user sees a clear message with a reference code, and the details stay in the server log.
After this you will be able to
- connect a lead form to a Server Action with Zod validation on the server and per-field errors via
useActionState - block repeated submits on the client (
pending) and on the server (deduplication), and stop simple bots with a hidden field and a rate limit - handle a failing external service without leaking internal details to the browser
- test the form with Playwright e2e tests, including with JavaScript disabled
Know beforehand
Verified with
Next.js 16.4 (Cache Components)React 19.3Zod 4.6TypeScript 5.9Node.js 24 LTS (24.18)Playwright 1.64
Checked against docs: October 12, 2026
Contents
The task and the requirements
The task is a “Request a call back” form: name, phone, a comment and consent to data processing. No separate API route is needed: the submission is handled by a Server Action, an async function marked with "use server" that React calls from <form action> with a POST request to the current page. The basics are covered in the lesson “Next.js: Server Components, data fetching and Server Actions”. This recipe is a ready, hardened solution you can move into a project by copying six files.
- Server-side validation with a Zod schema; errors sit under their fields and typed values are kept
- The button is disabled while the request is in flight (
pending) - Submitting the same lead again does not create a duplicate
- A hidden trap field (honeypot) and a per-IP rate limit
- A CRM failure shows a clear message with a code and no internal details
- The form works before JavaScript loads
The code: schema, guards, action and form
There are six files: the schema and the form state type, the action, the client form, the page, and two server modules in lib/ for spam protection and for delivering the lead to an external system.
import { z } from "zod";
export const leadSchema = z.object({
name: z
.string()
.trim()
.min(2, "Enter your name, at least 2 characters.")
.max(80, "The name is too long."),
phone: z
.string()
.trim()
.regex(/^\+?[\d\s()-]{10,20}$/, "Enter a phone number like +7 700 000 00 00.")
.transform((value) => value.replace(/[^\d+]/g, "")),
message: z.string().trim().max(1000, "Keep the message under 1000 characters."),
consent: z.literal("on", { error: "Consent is required to process the request." }),
});
export type Lead = z.infer<typeof leadSchema>;
export type LeadField = keyof Lead;
export type LeadState = {
status: "idle" | "success" | "error";
message: string;
errors?: Partial<Record<LeadField, string[]>>;
values?: { name: string; phone: string; message: string };
};Errors are turned into a “field → messages” object with z.flattenError, which the Zod 4 docs recommend for flat schemas. Consent is z.literal("on"): that is what a browser sends for a checked checkbox without a value attribute, and an unchecked one is not sent at all.
import "server-only";
// In-memory guards work inside one Node.js process (next start on one server).
// With several instances or serverless functions, keep the counters in Redis or the database.
const hits = new Map<string, number[]>();
const delivered = new Map<string, number>();
export function isRateLimited(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((time) => now - time < windowMs);
const limited = recent.length >= limit;
if (!limited) recent.push(now);
hits.set(key, recent);
return limited;
}
export function isDuplicate(key: string, ttlMs: number): boolean {
const now = Date.now();
for (const [k, time] of delivered) if (now - time > ttlMs) delivered.delete(k);
return delivered.has(key);
}
export function rememberDelivery(key: string): void {
delivered.set(key, Date.now());
}import "server-only";
import type { Lead } from "@/app/contact/schema";
// Sends the lead to a CRM, an automation webhook or your own API.
// The URL and token live in environment variables and never reach the browser.
export async function deliverLead(lead: Lead): Promise<void> {
const url = process.env.LEADS_WEBHOOK_URL;
if (!url) throw new Error("LEADS_WEBHOOK_URL is not set");
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.LEADS_WEBHOOK_TOKEN ?? ""}`,
},
body: JSON.stringify({ ...lead, source: "contact-form", createdAt: new Date().toISOString() }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
// The details go to the server log only (see actions.ts).
throw new Error(`Lead webhook answered ${res.status}: ${await res.text()}`);
}
}"use server";
import { headers } from "next/headers";
import { z } from "zod";
import { isDuplicate, isRateLimited, rememberDelivery } from "@/lib/abuse-guard";
import { deliverLead } from "@/lib/leads";
import { leadSchema, type LeadState } from "./schema";
const LIMIT = 5; // accepted requests per IP
const WINDOW_MS = 10 * 60 * 1000; // within 10 minutes
function text(formData: FormData, key: string): string {
const value = formData.get(key);
return typeof value === "string" ? value : "";
}
export async function submitLead(_prev: LeadState, formData: FormData): Promise<LeadState> {
const values = {
name: text(formData, "name"),
phone: text(formData, "phone"),
message: text(formData, "message"),
};
const thanks: LeadState = { status: "success", message: "Thank you! We will call you back." };
// 1. Honeypot: people do not see this field, simple bots fill every input.
if (text(formData, "website") !== "") return thanks;
// 2. Validation on the server: a request can bypass the form entirely.
const parsed = leadSchema.safeParse({ ...values, consent: formData.get("consent") });
if (!parsed.success) {
return {
status: "error",
message: "Please check the highlighted fields.",
errors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const lead = parsed.data;
// 3. Repeated submit (double click, retry, back button): same answer, one delivery.
const dedupeKey = `${lead.phone}|${lead.message}`;
if (isDuplicate(dedupeKey, WINDOW_MS)) return thanks;
// 4. Rate limit before the expensive part.
const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (isRateLimited(`lead:${ip}`, LIMIT, WINDOW_MS)) {
return { status: "error", message: "Too many requests. Please try again later.", values };
}
// 5. Delivery: the details of a failure stay in the server log.
try {
await deliverLead(lead);
} catch (error) {
const ref = crypto.randomUUID().slice(0, 8);
console.error(`[lead ${ref}]`, error);
return {
status: "error",
message: `We could not send the request. Please try again or call us. Code: ${ref}`,
values,
};
}
rememberDelivery(dedupeKey);
return thanks;
}Honeypot
People never see the
websitefield, while simple bots fill in everything. The bot gets the usual “thank you”, so it has nothing to learn from, and the lead goes nowhere.Validation
Expected errors are returned as a value, not thrown, as the Error Handling section of the Next.js docs advises. The typed values travel back with the errors: React resets uncontrolled fields after an action completes successfully, and “returned errors” counts as success for React.
Duplicate
The key is the phone plus the message. If the same lead was delivered within the last 10 minutes, we answer as before and send nothing. The lead is remembered only after a successful delivery; otherwise a retry after a CRM failure would be silently lost.
Rate limit
Five accepted leads per IP per 10 minutes. Validation errors do not count: someone who mistyped the phone three times should not hit a wall. The Next.js docs recommend rate limiting expensive operations specifically, such as sending emails and writing to a database.
Delivery
Any failure is caught, logged with a short code, and the user gets a neutral message with the same code, which support can use to find the log entry.
"use client";
import { useActionState } from "react";
import { submitLead } from "./actions";
import type { LeadField, LeadState } from "./schema";
const initialState: LeadState = { status: "idle", message: "" };
export function LeadForm() {
const [state, formAction, pending] = useActionState(submitLead, initialState);
const fieldError = (field: LeadField) => state.errors?.[field]?.[0];
const a11y = (field: LeadField) => ({
"aria-invalid": fieldError(field) ? true : undefined,
"aria-describedby": fieldError(field) ? `${field}-error` : undefined,
});
const errorText = (field: LeadField) =>
fieldError(field) && (
<p id={`${field}-error`} className="text-sm text-red-700">
{fieldError(field)}
</p>
);
return (
<form action={formAction} className="flex max-w-md flex-col gap-2">
<label htmlFor="name">Name</label>
<input id="name" name="name" required autoComplete="name"
defaultValue={state.values?.name} {...a11y("name")} className="border p-2" />
{errorText("name")}
<label htmlFor="phone">Phone</label>
<input id="phone" name="phone" type="tel" required autoComplete="tel"
defaultValue={state.values?.phone} {...a11y("phone")} className="border p-2" />
{errorText("phone")}
<label htmlFor="message">What do you need?</label>
<textarea id="message" name="message" maxLength={1000}
defaultValue={state.values?.message} {...a11y("message")} className="border p-2" />
{errorText("message")}
<label className="flex gap-2">
<input type="checkbox" name="consent" required {...a11y("consent")} />
I agree to the processing of my personal data
</label>
{errorText("consent")}
{/* Honeypot: hidden from people and screen readers, so they leave it empty */}
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label htmlFor="website">Leave this field empty</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={pending}
className="bg-black p-2 text-white disabled:opacity-50">
{pending ? "Sending…" : "Send request"}
</button>
<p role="status" className={state.status === "error" ? "text-red-700" : "text-green-700"}>
{state.message}
</p>
</form>
);
}useActionState, per-field errors, the honeypot and a disabled buttonuseActionState returns three values: the state (the last action response), the wrapped action for the action attribute and the pending flag. While pending is true, the button is disabled and reads “Sending…”. An error is tied to its field with aria-invalid and aria-describedby, and the overall message goes into an element with role="status", which a screen reader announces without moving focus. The reasoning is covered in the lesson “Forms and validation in React”.
import type { Metadata } from "next";
import { LeadForm } from "./LeadForm";
export const metadata: Metadata = { title: "Contact us" };
export default function ContactPage() {
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="mb-6 text-2xl font-semibold">Request a call back</h1>
<LeadForm />
</main>
);
}How to plug it in
npm i zod server-only
npm i -D @playwright/test
npx playwright install chromiumFiles
Copy the six files. The
@/prefix in imports is thetsconfig.jsonalias thatcreate-next-appsets up.server-onlyImporting
"server-only"inlib/turns an accidental import of the module into a Client Component into a build error, so the CRM token never reaches the browser bundle.Environment variables
LEADS_WEBHOOK_URLis an endpoint that accepts the lead as JSON: a CRM webhook, an automation scenario or your own API. The token goes intoLEADS_WEBHOOK_TOKEN. Without theNEXT_PUBLIC_prefix, both variables are available on the server only.Where to send
For Telegram, replace the body of
deliverLeadwith a Bot API call; the bot itself is covered in “Telegram bot: Bot API, commands and a webhook”. For a CRM, see “CRM integration”. The function signature stays the same, so the action and the form do not change.
LEADS_WEBHOOK_URL=https://crm.example.com/hooks/leads
LEADS_WEBHOOK_TOKEN=YOUR_WEBHOOK_TOKENThe form works without JavaScript. Until the bundle loads (slow network, a script error), the browser sends a regular POST, Next.js runs the action and returns the page already rendered with the new state: field errors or a success message. That is how useActionState behaves with a Server Function; when the form updates a different page, the hook takes a third permalink argument. We verified this with an e2e test that has JavaScript turned off.
How to verify it
We verify with Playwright e2e tests against a production build (next build and next start). A stub replaces the real CRM: it keeps leads in memory, returns them to the tests on GET /leads, and answers a name containing “Crash” with a 500 error that has a “password” in its body, so we can see whether it leaks to the browser.
// Test double for the CRM webhook: stores leads in memory, fails on demand.
import { createServer } from "node:http";
const leads = [];
createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/leads") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify(leads));
}
if (req.method === "POST" && req.url === "/leads") {
if (req.headers.authorization !== "Bearer test-token") {
res.writeHead(401);
return res.end("unauthorized");
}
let body = "";
for await (const chunk of req) body += chunk;
const lead = JSON.parse(body);
if (lead.name.includes("Crash")) {
res.writeHead(500);
return res.end("db error: password=hunter2 host=10.0.0.5");
}
leads.push(lead);
res.writeHead(201);
return res.end();
}
res.writeHead(404);
res.end();
}).listen(4010);import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
reporter: "list",
use: { baseURL: "http://localhost:3100", ...devices["Desktop Chrome"] },
webServer: [
{ command: "node e2e/mock-crm.mjs", url: "http://localhost:4010/leads" },
{
command: "npm run start -- -p 3100",
url: "http://localhost:3100/contact",
env: { LEADS_WEBHOOK_URL: "http://localhost:4010/leads", LEADS_WEBHOOK_TOKEN: "test-token" },
},
],
});next start itself; run npm run build before the testsimport { expect, test, type Page } from "@playwright/test";
const CRM = "http://localhost:4010/leads";
async function deliveredTo(phone: string): Promise<number> {
const leads: { phone: string }[] = await (await fetch(CRM)).json();
return leads.filter((lead) => lead.phone === phone).length;
}
async function fill(page: Page, name: string, phone: string) {
await page.goto("/contact");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Phone").fill(phone);
await page.getByLabel("What do you need?").fill("Website for a coffee shop");
await page.getByLabel(/I agree/).check();
}
test("server validation: errors at fields, values kept", async ({ page }) => {
await fill(page, "A", "abc");
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toHaveText("Please check the highlighted fields.");
await expect(page.locator("#name-error")).toHaveText("Enter your name, at least 2 characters.");
await expect(page.locator("#phone-error")).toContainText("+7 700 000 00 00");
await expect(page.getByLabel("Name")).toHaveAttribute("aria-invalid", "true");
await expect(page.getByLabel("Phone")).toHaveValue("abc");
});
test("valid lead: delivered once, form reset", async ({ page }) => {
await fill(page, "Aigerim", "+7 (700) 000-00-01");
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toHaveText("Thank you! We will call you back.");
await expect(page.getByLabel("Name")).toHaveValue("");
expect(await deliveredTo("+77000000001")).toBe(1);
});
test("button is disabled while the action is pending", async ({ page }) => {
await fill(page, "Daniyar", "+7 700 000 00 02");
await page.route("**/contact", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000)); // slow network
await route.continue();
});
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("button", { name: "Sending…" })).toBeDisabled();
await expect(page.getByRole("status")).toHaveText("Thank you! We will call you back.");
});
test("double submit delivers the lead once", async ({ page }) => {
await fill(page, "Aliya", "+7 700 000 00 03");
await page.locator("form").evaluate((form: HTMLFormElement) => {
form.requestSubmit();
form.requestSubmit();
});
await expect(page.getByRole("status")).toHaveText("Thank you! We will call you back.");
await expect.poll(() => deliveredTo("+77000000003")).toBe(1);
});
test("honeypot: bot sees success, nothing is delivered", async ({ page }) => {
await fill(page, "Bot", "+7 700 000 00 04");
await page.locator("#website").fill("https://spam.example");
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toHaveText("Thank you! We will call you back.");
expect(await deliveredTo("+77000000004")).toBe(0);
});
test("CRM failure: generic message, no internal details", async ({ page }) => {
await fill(page, "Crash Test", "+7 700 000 00 05");
let body = "";
await page.route("**/contact", async (route) => {
if (route.request().method() !== "POST") return route.continue();
const response = await route.fetch(); // keep a copy of the action response
body = await response.text();
await route.fulfill({ response, body });
});
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toContainText("We could not send the request.");
await expect(page.getByLabel("Name")).toHaveValue("Crash Test");
expect(body).toContain("We could not send the request.");
expect(body).not.toContain("hunter2");
});
test.describe("without JavaScript", () => {
test.use({ javaScriptEnabled: false });
test("the form still validates and sends", async ({ page }) => {
await fill(page, "A", "+7 700 000 00 06");
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.locator("#name-error")).toBeVisible();
await page.getByLabel("Name").fill("Arman");
await page.getByLabel(/I agree/).check();
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toHaveText("Thank you! We will call you back.");
expect(await deliveredTo("+77000000006")).toBe(1);
});
});
test.describe("rate limit", () => {
test.use({ extraHTTPHeaders: { "x-forwarded-for": "203.0.113.7" } });
test("the sixth request in 10 minutes is refused", async ({ page }) => {
for (let i = 1; i <= 6; i++) {
await fill(page, "Sanzhar", `+7 700 000 10 0${i}`);
await page.getByRole("button", { name: "Send request" }).click();
await expect(page.getByRole("status")).toHaveText(
i <= 5 ? "Thank you! We will call you back." : "Too many requests. Please try again later.",
);
}
expect(await deliveredTo("+77000001006")).toBe(0);
});
});$ npm run build
…
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /contact
$ npx playwright test
Running 8 tests using 1 worker
ok 1 e2e\lead-form.spec.ts:18:5 › server validation: errors at fields, values kept (371ms)
ok 2 e2e\lead-form.spec.ts:29:5 › valid lead: delivered once, form reset (434ms)
ok 3 e2e\lead-form.spec.ts:38:5 › button is disabled while the action is pending (1.6s)
ok 4 e2e\lead-form.spec.ts:50:5 › double submit delivers the lead once (211ms)
ok 5 e2e\lead-form.spec.ts:61:5 › honeypot: bot sees success, nothing is delivered (268ms)
[WebServer] [lead f4ae9df5] Error: Lead webhook answered 500: db error: password=hunter2 host=10.0.0.5
ok 6 e2e\lead-form.spec.ts:70:5 › CRM failure: generic message, no internal details (233ms)
ok 7 e2e\lead-form.spec.ts:90:7 › without JavaScript › the form still validates and sends (419ms)
ok 8 e2e\lead-form.spec.ts:106:7 › rate limit › the sixth request in 10 minutes is refused (1.0s)
8 passed (10.3s)We checked the tests the other way round. Without the duplicate check, the double-submit test fails: the stub received two leads instead of one. Without the try/catch around delivery, the CRM failure test fails. In a production build, Next.js does not send the exception text by itself (the action response held only a numeric digest), but the whole page was replaced by a “This page couldn’t load” screen with that code, and the typed data was gone. In development, the exception message does reach the browser; that debugging behaviour is described in the error.js docs. So expected failures are handled inside the action and returned as a value.
Limitations and production replacements
Counters in a Map
- Limitation
- They live in one process: several instances or serverless functions each have their own counters, and a restart resets everything
- Replace with
- Redis or a database table; for duplicates, a unique index on the lead key
IP from x-forwarded-for
- Limitation
- We checked:
next startpasses through a header sent by the client, and the rate limit test sets it itself. Without a trusted proxy, a bot bypasses the limit by changing the header - Replace with
- The IP set by your proxy or platform that the client cannot overwrite; in Nginx, overwrite the header instead of appending to it
Honeypot
- Limitation
- Stops only simple bots; browser autofill occasionally fills hidden fields
- Replace with
- For real spam, a CAPTCHA service or platform-level protection; watch the logs for lost genuine leads
Duplicate by phone and message
- Limitation
- Two different requests with the same text within 10 minutes count as one
- Replace with
- For orders and payments, an idempotency key issued when the form opens
Synchronous delivery
- Limitation
- The user waits for the CRM for up to 5 seconds
- Replace with
- Write to your own database first, send the Telegram notification with
after()once the response is out
Two more points from the Server Actions docs. The request body is capped at 1 MB by default (serverActions.bodySizeLimit), which is plenty for a lead but not for file uploads. Action IDs change between builds: a form on a page opened before a deployment will get a “Failed to find Server Action” error. The docs suggest offering a page refresh in that case and avoiding abrupt cutovers while users are actively submitting forms.
Related material and next step
The mechanics of Server Actions, caching and updateTag (in case leads must appear in an admin panel right away) are covered in the lesson “Next.js: Server Components, data fetching and Server Actions”. In-browser validation before submit and accessible errors are in “Forms and validation in React”: the same Zod schema plugs into React Hook Form while the action stays as is. Which fields to ask for at all is discussed in “Painless forms”. The next recipe is “Protected pages in Next.js”: how to lock down the dashboard where these leads will be read.
How this was verified4 · passed1 · not runchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.