How to buildOctober 12, 20268 min read
Next.jsStep 6 of 7

Recipe: a lead form on a Server Action with validation

In short

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.

Code recipeLevel: IntermediateNext.js SolutionsPractice: ~40–90 min

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

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
  1. 01The task and the requirements
  2. 02The code: schema, guards, action and form
  3. 03How to plug it in
  4. 04How to verify it
  5. 05Limitations and production replacements
  6. 06Related material and next step

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
FormHoneypotZodDuplicate?IP limit
The order of checks in the Server Action: cheap ones first, the expensive delivery after every filter.

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.

app/contact/schema.ts
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 };
};
The Zod schema and the action response type; the phone is normalised to digits and “+”

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.

lib/abuse-guard.ts
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());
}
In-process counters: the rate limit and the memory of delivered leads
lib/leads.ts
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()}`);
  }
}
Delivery to an external system: URL and token from environment variables, a 5-second timeout
app/contact/actions.ts
"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;
}
The Server Action: five steps from cheap checks to the expensive delivery
  1. Honeypot

    People never see the website field, 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

app/contact/LeadForm.tsx
"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>
  );
}
The client form: useActionState, per-field errors, the honeypot and a disabled button

useActionState 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”.

app/contact/page.tsx
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>
  );
}
The page is a Server Component and is prerendered as static at build time

How to plug it in

Terminal
npm i zod server-only
npm i -D @playwright/test
npx playwright install chromium
Dependencies for the recipe and the tests
  1. Files

    Copy the six files. The @/ prefix in imports is the tsconfig.json alias that create-next-app sets up.

  2. server-only

    Importing "server-only" in lib/ turns an accidental import of the module into a Client Component into a build error, so the CRM token never reaches the browser bundle.

  3. Environment variables

    LEADS_WEBHOOK_URL is an endpoint that accepts the lead as JSON: a CRM webhook, an automation scenario or your own API. The token goes into LEADS_WEBHOOK_TOKEN. Without the NEXT_PUBLIC_ prefix, both variables are available on the server only.

  4. Where to send

    For Telegram, replace the body of deliverLead with 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.

.env.local
LEADS_WEBHOOK_URL=https://crm.example.com/hooks/leads
LEADS_WEBHOOK_TOKEN=YOUR_WEBHOOK_TOKEN
Placeholders: put in your own values and do not commit the file

The 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.

e2e/mock-crm.mjs
// 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);
A CRM test double: accepts leads and fails on demand
playwright.config.ts
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" },
    },
  ],
});
Playwright starts the stub and next start itself; run npm run build before the tests
e2e/lead-form.spec.ts
import { 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);
  });
});
Eight scenarios: validation, success, pending, duplicate, honeypot, CRM failure, no JavaScript, rate limit
Terminal
$ 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)
Real build and test output (Windows, Playwright’s Chromium); the [WebServer] line is the action’s server log

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 start passes 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.