Форма заявки в Next.js 16 обходится без отдельного API-маршрута: <form> вызывает Server Action, который проверяет данные схемой Zod на сервере и возвращает ошибки полей обычным значением, а useActionState показывает их и даёт флаг pending для блокировки кнопки. Защита от спама и дублей тоже живёт на сервере: скрытое поле-ловушка (honeypot), лимит запросов с одного IP и повторная отправка, которая не создаёт вторую заявку. Сбой CRM пользователь видит как понятное сообщение с кодом, а подробности остаются в серверном логе.
После материала вы сможете
- подключить форму заявки к Server Action с серверной валидацией Zod и ошибками у полей через
useActionState - заблокировать повторную отправку на клиенте (
pending) и на сервере (дедупликация), отсечь простых ботов скрытым полем и лимитом запросов - обработать сбой внешнего сервиса так, чтобы в браузер не ушли внутренние детали
- проверить форму e2e-тестами Playwright, в том числе с выключенным JavaScript
Что знать заранее
Проверено на
Next.js 16.4 (Cache Components)React 19.3Zod 4.6TypeScript 5.9Node.js 24 LTS (24.18)Playwright 1.64
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача и требования к форме
Задача — форма «Оставить заявку»: имя, телефон, комментарий и согласие на обработку данных. Отдельный API-маршрут не нужен: отправку принимает Server Action — асинхронная функция с директивой "use server", которую React вызывает из <form action> POST-запросом на текущую страницу. Основы Server Actions разобраны в уроке «Next.js: серверные компоненты, загрузка данных и Server Actions». Здесь — готовое решение с защитой, которое переносится в проект копированием шести файлов.
- Валидация на сервере схемой Zod; ошибки — под своими полями, введённые значения не теряются
- Кнопка заблокирована, пока запрос в пути (
pending) - Повторная отправка той же заявки не создаёт дубль
- Скрытое поле-ловушка (honeypot) и лимит запросов с одного IP
- Сбой CRM — понятное сообщение с кодом, без внутренних деталей
- Форма работает и до загрузки JavaScript
Готовый код: схема, защита, действие и форма
Файлов шесть: схема и тип состояния формы, действие, клиентская форма, страница и два серверных модуля в lib/ — защита от спама и доставка заявки во внешнюю систему.
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 };
};Ошибки разворачиваем в объект «поле → сообщения» функцией z.flattenError — для плоских схем её рекомендует документация Zod 4. Согласие описано как z.literal("on"): так браузер отправляет отмеченный чекбокс без атрибута value, а неотмеченный не отправляет вовсе.
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
Скрытое поле
websiteлюди не видят, а простые боты заполняют всё подряд. Бот получает обычное «спасибо», чтобы ему было не на чем учиться, а заявка никуда не уходит.Валидация
Ожидаемые ошибки возвращаем значением, а не бросаем исключение — так советует раздел Error Handling документации Next.js. Вместе с ошибками возвращаем введённые значения: React после успешного завершения действия сбрасывает неуправляемые поля формы, а «вернули ошибки» для него тоже успех.
Дубль
Ключ — телефон и текст. Если такая заявка уже доставлена за последние 10 минут, отвечаем как в первый раз и ничего не отправляем. Запоминаем заявку только после успешной доставки, иначе повтор после сбоя CRM молча потерялся бы.
Лимит
Пять принятых заявок с одного IP за 10 минут. Ошибки валидации в лимит не входят: человек, который трижды ошибся в телефоне, не должен упереться в запрет. Документация Next.js советует ограничивать частоту именно дорогих операций — отправки писем и записи в базу.
Доставка
Любой сбой ловим, пишем в лог с коротким кодом и возвращаем нейтральное сообщение с тем же кодом — по нему поддержка найдёт запись в логе.
"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, ошибки у полей, honeypot и блокировка кнопкиuseActionState возвращает три значения: состояние (последний ответ действия), обёрнутое действие для атрибута action и флаг pending. Пока pending равен true, кнопка заблокирована и показывает «Sending…». Ошибка связана с полем через aria-invalid и aria-describedby, общее сообщение выводится в элементе с role="status" — скринридер зачитает его без переноса фокуса. Почему так, подробно разобрано в уроке «Формы и валидация в 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>
);
}Как подключить
npm i zod server-only
npm i -D @playwright/test
npx playwright install chromiumФайлы
Скопируйте шесть файлов. Префикс
@/в импортах — алиас изtsconfig.json, который настраиваетcreate-next-app.server-onlyИмпорт
"server-only"вlib/превращает случайное подключение модуля в клиентский компонент в ошибку сборки: токен CRM не попадёт в браузерный бандл.Переменные окружения
LEADS_WEBHOOK_URL— адрес, который принимает JSON с заявкой: вебхук CRM, сценарий автоматизации или ваш API. Токен — вLEADS_WEBHOOK_TOKEN. Без префиксаNEXT_PUBLIC_обе переменные доступны только на сервере.Куда отправлять
Для Telegram замените тело
deliverLeadвызовом Bot API — как устроен бот, разобрано в уроке «Telegram-бот: Bot API, команды и вебхук». Для CRM — руководство «Интеграция с CRM». Сигнатура функции не меняется, действие и форма остаются прежними.
LEADS_WEBHOOK_URL=https://crm.example.com/hooks/leads
LEADS_WEBHOOK_TOKEN=YOUR_WEBHOOK_TOKENФорма работает без JavaScript. Пока бандл не загрузился (медленная сеть, ошибка скрипта), браузер отправляет обычный POST, Next.js выполняет действие и возвращает страницу, уже отрисованную с новым состоянием: ошибками у полей или сообщением об успехе. Так useActionState ведёт себя с Server Function; для случая, когда форма меняет другую страницу, у хука есть третий аргумент permalink. Мы проверили это e2e-тестом с выключенным JavaScript.
Как проверить
Проверяем e2e-тестами Playwright против production-сборки (next build и next start). Вместо настоящей CRM поднимается заглушка: хранит заявки в памяти, отдаёт их тестам по GET /leads, а на имя с «Crash» отвечает ошибкой 500 с «паролем» в теле — так видно, утечёт ли он в браузер.
// 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; перед тестами нужен npm run buildimport { 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)Тесты мы проверили от обратного. Без проверки дубля тест двойной отправки падает: заглушка получила две заявки вместо одной. Без try/catch вокруг доставки падает тест сбоя CRM. Текст исключения Next.js в production-сборке сам не отдаёт — в ответе действия был только числовой digest, — но вся страница сменилась экраном «This page couldn’t load» с этим кодом, и введённые данные пропали. В режиме разработки сообщение исключения доходит до браузера: это отладочное поведение описано в документации error.js. Поэтому ожидаемые сбои обрабатываем в действии и возвращаем значением.
Ограничения и что заменить в продакшне
Счётчики в Map
- Ограничение
- Живут в памяти одного процесса: у нескольких экземпляров или serverless-функций свои счётчики, после перезапуска всё обнуляется
- Чем заменить
- Redis или таблица в базе; для дублей — уникальный индекс по ключу заявки
IP из x-forwarded-for
- Ограничение
- Мы проверили:
next startпропускает заголовок, присланный клиентом, — тест лимита подставляет его сам. Без доверенного прокси бот обойдёт лимит, меняя заголовок - Чем заменить
- IP, который проставляет ваш прокси или платформа и который клиент не может переписать; в Nginx заголовок нужно перезаписывать, а не дополнять
Honeypot
- Ограничение
- Отсекает только простых ботов; браузерное автозаполнение изредка заполняет скрытые поля
- Чем заменить
- При настоящем спаме — CAPTCHA-сервис или защита на стороне платформы; следите в логах, не теряются ли живые заявки
Дубль по телефону и тексту
- Ограничение
- Два разных обращения с одинаковым текстом за 10 минут считаются одним
- Чем заменить
- Для заказов и оплат — ключ идемпотентности, выданный при открытии формы
Синхронная доставка
- Ограничение
- Пользователь ждёт ответа CRM до 5 секунд
- Чем заменить
- Сначала запись в свою базу, уведомление в Telegram — через
after()после ответа
Ещё два момента из документации Server Actions. Тело запроса по умолчанию ограничено 1 МБ (serverActions.bodySizeLimit) — для заявки хватает, для загрузки файлов нет. Идентификаторы действий меняются между сборками: форма со страницы, открытой до деплоя, получит ошибку «Failed to find Server Action». Документация советует предлагать в этом случае обновить страницу и не выкатывать релизы резко, пока пользователи активно отправляют формы.
Связанные материалы и следующий шаг
Механику Server Actions, кэш и updateTag — если заявки нужно сразу показывать в админке — разбирает урок «Next.js: серверные компоненты, загрузка данных и Server Actions». Проверку в браузере до отправки и доступность ошибок — урок «Формы и валидация в React»: ту же схему Zod можно подключить к React Hook Form, оставив действие без изменений. Какие поля вообще стоит спрашивать — материал «Формы без боли». Следующий рецепт — «Закрытые страницы в Next.js»: как защитить кабинет, где эти заявки будут читать.
Как проверено4 · пройдено1 · не запускалосьсверено 12 октября 2026 г.
- запущено, работает
app/contact/schema.ts, actions.ts, LeadForm.tsx, page.tsx, lib/abuse-guard.ts, lib/leads.ts
create-next-app@16.4.0 --yes (Cache Components, React 19.3.0, TS 5.9.3) + zod 4.6.6 + server-only: tsc --noEmit OK, eslint OK, next build OK (/contact ○ Static); код статьи считан генератором из файлов проекта
- запущено, работает
e2e/mock-crm.mjs, playwright.config.ts, e2e/lead-form.spec.ts
@playwright/test 1.64.0 (Chromium) против next start + заглушка CRM: 8/8 passed; итоговая версия тестов прогнана 2 раза подряд без падений; вывод в статье — реальный
- запущено, работает
Проверка тестов от обратного
без isDuplicate тест двойной отправки упал (2 доставки вместо 1); без try/catch тест сбоя CRM упал: в production ответ действия содержал только digest, страница — экран «This page couldn’t load», текст исключения в ответ не попал; код восстановлен
- запущено, работает
x-forwarded-for в next start
лог действия: без заголовка x-forwarded-for = ::1; с заголовком от клиента (extraHTTPHeaders) используется присланное значение — тест лимита проходит 5 заявок при исчерпанном лимите ::1
- не запускалось
Упражнение «Поле Услуга»
задача с подсказкой без готового решения — кода в статье нет
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.