Как делать12 октября 2026 г.7 мин чтения
Next.jsШаг 6 из 7

Рецепт: форма заявки на Server Action с валидацией

Коротко

Форма заявки в Next.js 16 обходится без отдельного API-маршрута: <form> вызывает Server Action, который проверяет данные схемой Zod на сервере и возвращает ошибки полей обычным значением, а useActionState показывает их и даёт флаг pending для блокировки кнопки. Защита от спама и дублей тоже живёт на сервере: скрытое поле-ловушка (honeypot), лимит запросов с одного IP и повторная отправка, которая не создаёт вторую заявку. Сбой CRM пользователь видит как понятное сообщение с кодом, а подробности остаются в серверном логе.

Рецепт кодаУровень: СреднийРешения для Next.jsПрактика: ~40–90 мин

После материала вы сможете

  • подключить форму заявки к 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 г.

Содержание
  1. 01Задача и требования к форме
  2. 02Готовый код: схема, защита, действие и форма
  3. 03Как подключить
  4. 04Как проверить
  5. 05Ограничения и что заменить в продакшне
  6. 06Связанные материалы и следующий шаг

Задача и требования к форме

Задача — форма «Оставить заявку»: имя, телефон, комментарий и согласие на обработку данных. Отдельный API-маршрут не нужен: отправку принимает Server Action — асинхронная функция с директивой "use server", которую React вызывает из <form action> POST-запросом на текущую страницу. Основы Server Actions разобраны в уроке «Next.js: серверные компоненты, загрузка данных и Server Actions». Здесь — готовое решение с защитой, которое переносится в проект копированием шести файлов.

  • Валидация на сервере схемой Zod; ошибки — под своими полями, введённые значения не теряются
  • Кнопка заблокирована, пока запрос в пути (pending)
  • Повторная отправка той же заявки не создаёт дубль
  • Скрытое поле-ловушка (honeypot) и лимит запросов с одного IP
  • Сбой CRM — понятное сообщение с кодом, без внутренних деталей
  • Форма работает и до загрузки JavaScript
ФормаHoneypotZodДубль?Лимит IP
Порядок проверок в Server Action: дешёвые — раньше, дорогая доставка — после всех фильтров.

Готовый код: схема, защита, действие и форма

Файлов шесть: схема и тип состояния формы, действие, клиентская форма, страница и два серверных модуля в lib/ — защита от спама и доставка заявки во внешнюю систему.

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 };
};
Схема Zod и тип ответа действия; телефон нормализуется до цифр и «+»

Ошибки разворачиваем в объект «поле → сообщения» функцией z.flattenError — для плоских схем её рекомендует документация Zod 4. Согласие описано как z.literal("on"): так браузер отправляет отмеченный чекбокс без атрибута value, а неотмеченный не отправляет вовсе.

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());
}
Счётчики в памяти процесса: лимит запросов и память о доставленных заявках
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()}`);
  }
}
Доставка во внешнюю систему: адрес и токен из переменных окружения, тайм-аут 5 секунд
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;
}
Server Action: пять шагов от дешёвых проверок к дорогой доставке
  1. Honeypot

    Скрытое поле website люди не видят, а простые боты заполняют всё подряд. Бот получает обычное «спасибо», чтобы ему было не на чем учиться, а заявка никуда не уходит.

  2. Валидация

    Ожидаемые ошибки возвращаем значением, а не бросаем исключение — так советует раздел Error Handling документации Next.js. Вместе с ошибками возвращаем введённые значения: React после успешного завершения действия сбрасывает неуправляемые поля формы, а «вернули ошибки» для него тоже успех.

  3. Дубль

    Ключ — телефон и текст. Если такая заявка уже доставлена за последние 10 минут, отвечаем как в первый раз и ничего не отправляем. Запоминаем заявку только после успешной доставки, иначе повтор после сбоя CRM молча потерялся бы.

  4. Лимит

    Пять принятых заявок с одного IP за 10 минут. Ошибки валидации в лимит не входят: человек, который трижды ошибся в телефоне, не должен упереться в запрет. Документация Next.js советует ограничивать частоту именно дорогих операций — отправки писем и записи в базу.

  5. Доставка

    Любой сбой ловим, пишем в лог с коротким кодом и возвращаем нейтральное сообщение с тем же кодом — по нему поддержка найдёт запись в логе.

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>
  );
}
Клиентская форма: useActionState, ошибки у полей, honeypot и блокировка кнопки

useActionState возвращает три значения: состояние (последний ответ действия), обёрнутое действие для атрибута action и флаг pending. Пока pending равен true, кнопка заблокирована и показывает «Sending…». Ошибка связана с полем через aria-invalid и aria-describedby, общее сообщение выводится в элементе с role="status" — скринридер зачитает его без переноса фокуса. Почему так, подробно разобрано в уроке «Формы и валидация в 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>
  );
}
Страница — серверный компонент; при сборке она пререндерится как статическая

Как подключить

Terminal
npm i zod server-only
npm i -D @playwright/test
npx playwright install chromium
Зависимости рецепта и тестов
  1. Файлы

    Скопируйте шесть файлов. Префикс @/ в импортах — алиас из tsconfig.json, который настраивает create-next-app.

  2. server-only

    Импорт "server-only" в lib/ превращает случайное подключение модуля в клиентский компонент в ошибку сборки: токен CRM не попадёт в браузерный бандл.

  3. Переменные окружения

    LEADS_WEBHOOK_URL — адрес, который принимает JSON с заявкой: вебхук CRM, сценарий автоматизации или ваш API. Токен — в LEADS_WEBHOOK_TOKEN. Без префикса NEXT_PUBLIC_ обе переменные доступны только на сервере.

  4. Куда отправлять

    Для Telegram замените тело deliverLead вызовом Bot API — как устроен бот, разобрано в уроке «Telegram-бот: Bot API, команды и вебхук». Для CRM — руководство «Интеграция с CRM». Сигнатура функции не меняется, действие и форма остаются прежними.

.env.local
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 с «паролем» в теле — так видно, утечёт ли он в браузер.

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);
Тестовый двойник CRM: принимает заявки и падает по требованию
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 сам запускает заглушку и next start; перед тестами нужен npm run build
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);
  });
});
Восемь сценариев: валидация, успех, pending, дубль, honeypot, сбой CRM, без JavaScript, лимит
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)
Реальный вывод сборки и тестов (Windows, Chromium из Playwright); строка [WebServer] — серверный лог действия

Тесты мы проверили от обратного. Без проверки дубля тест двойной отправки падает: заглушка получила две заявки вместо одной. Без 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.