Server Actions в Next.js: формы без API-роутов

Server Actions появились в Next.js 14 и перевернули подход к формам. Больше не нужно писать API-роут для каждой формы, создавать fetch-обёртки на клиенте и вручную управлять состоянием загрузки. Вы пишете серверную функцию — и вызываете её прямо из формы. Разбираем всё: от базовых примеров до продвинутых паттернов.

Что такое Server Actions

Server Action — это асинхронная функция, которая выполняется на сервере, но вызывается с клиента. Она помечается директивой "use server". Next.js автоматически создаёт эндпоинт для неё — вам не нужно писать API-роут.

Простейший пример:

"use server";

export async function subscribe(formData: FormData) {
  const email = formData.get("email") as string;

  await db.subscriber.create({ data: { email } });
}

export default function NewsletterForm() {
  return (
    <form action={subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Подписаться</button>
    </form>
  );
}

Это работает даже без JavaScript на клиенте — форма отправляется как обычный HTML-форм. Progressive enhancement из коробки.

Валидация с Zod

Данные из формы — это просто строки. Их нужно валидировать. Zod — лучший выбор для TypeScript-проектов:

pnpm add zod

src/app/actions/contact.ts:

"use server";

import { z } from "zod";
import { prisma } from "@/lib/prisma";

const contactSchema = z.object({
  name: z.string().min(2, "Имя должно быть не короче 2 символов"),
  email: z.string().email("Некорректный email"),
  phone: z
    .string()
    .regex(/^\+7\d{10}$/, "Формат: +7XXXXXXXXXX")
    .optional()
    .or(z.literal("")),
  message: z.string().min(10, "Сообщение слишком короткое").max(2000),
  service: z.enum(["website", "design", "seo", "support"]),
});

export type ContactFormState = {
  success: boolean;
  errors?: Record<string, string[]>;
  message?: string;
};

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const raw = Object.fromEntries(formData);
  const result = contactSchema.safeParse(raw);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  try {
    await prisma.contactRequest.create({
      data: result.data,
    });

    return { success: true, message: "Заявка отправлена!" };
  } catch (error) {
    return {
      success: false,
      message: "Ошибка сервера. Попробуйте позже.",
    };
  }
}

Обратите внимание на сигнатуру: первый аргумент — предыдущее состояние, второй — FormData. Это формат для useActionState.

useActionState — управление состоянием формы

Хук useActionState (React 19) заменяет устаревший useFormState. Он управляет состоянием, отслеживает загрузку и хранит ошибки:

src/app/contact/page.tsx:

"use client";

import { useActionState } from "react";
import { submitContactForm, type ContactFormState } from "@/app/actions/contact";

const initialState: ContactFormState = { success: false };

export default function ContactPage() {
  const [state, formAction, isPending] = useActionState(submitContactForm, initialState);

  if (state.success) {
    return (
      <div className="bg-green-50 p-6 rounded-lg">
        <h2 className="text-green-800 text-xl font-bold">{state.message}</h2>
        Мы свяжемся с вами в течение рабочего дня.
      </div>
    );
  }

  return (
    <form action={formAction} className="flex flex-col gap-4 max-w-lg">
      <div>
        <label htmlFor="name">Имя</label>
        <input type="text" id="name" name="name" required className="w-full border p-2 rounded" />
        {state.errors?.name && (
          <span className="text-red-500 text-sm">{state.errors.name[0]}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required className="w-full border p-2 rounded" />
        {state.errors?.email && (
          <span className="text-red-500 text-sm">{state.errors.email[0]}</span>
        )}
      </div>

      <div>
        <label htmlFor="phone">Телефон</label>
        <input type="tel" id="phone" name="phone" placeholder="+7XXXXXXXXXX" className="w-full border p-2 rounded" />
        {state.errors?.phone && (
          <span className="text-red-500 text-sm">{state.errors.phone[0]}</span>
        )}
      </div>

      <div>
        <label htmlFor="service">Услуга</label>
        <select id="service" name="service" required className="w-full border p-2 rounded">
          <option value="">Выберите услугу</option>
          <option value="website">Разработка сайта</option>
          <option value="design">Дизайн</option>
          <option value="seo">SEO-продвижение</option>
          <option value="support">Поддержка</option>
        </select>
      </div>

      <div>
        <label htmlFor="message">Сообщение</label>
        <textarea id="message" name="message" rows={5} required className="w-full border p-2 rounded" />
        {state.errors?.message && (
          <span className="text-red-500 text-sm">{state.errors.message[0]}</span>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium disabled:opacity-50"
      >
        {isPending ? "Отправка..." : "Отправить заявку"}
      </button>

      {state.message && !state.success && (
        <div className="text-red-500">{state.message}</div>
      )}
    </form>
  );
}

Optimistic Updates — мгновенный UI

Хук useOptimistic позволяет обновить UI до завершения серверного запроса. Пример — список задач с переключением статуса:

"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTask } from "@/app/actions/tasks";

interface Task {
  id: string;
  title: string;
  done: boolean;
}

export function TaskList({ tasks }: { tasks: Task[] }) {
  const [optimisticTasks, setOptimisticTask] = useOptimistic(
    tasks,
    (currentTasks, taskId: string) =>
      currentTasks.map((t) => (t.id === taskId ? { ...t, done: !t.done } : t))
  );
  const [, startTransition] = useTransition();

  function handleToggle(taskId: string) {
    startTransition(async () => {
      setOptimisticTask(taskId);
      await toggleTask(taskId);
    });
  }

  return (
    <ul>
      {optimisticTasks.map((task) => (
        <li key={task.id} className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={task.done}
            onChange={() => handleToggle(task.id)}
          />
          <span className={task.done ? "line-through text-gray-400" : ""}>
            {task.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

Серверная часть:

"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function toggleTask(taskId: string) {
  const task = await prisma.task.findUniqueOrThrow({ where: { id: taskId } });

  await prisma.task.update({
    where: { id: taskId },
    data: { done: !task.done },
  });

  revalidatePath("/tasks");
}

Чекбокс переключается мгновенно. Если сервер вернёт ошибку — React автоматически откатит состояние к актуальному.

Ревалидация данных

После мутации нужно обновить кешированные данные. Два способа:

"use server";

import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";

export async function updateProject(id: string, data: any) {
  await prisma.project.update({ where: { id }, data });

  // Способ 1: ревалидация по пути
  revalidatePath("/projects");
  revalidatePath(`/projects/${id}`);

  // Способ 2: ревалидация по тегу (более точно)
  revalidateTag("projects");
  revalidateTag(`project-${id}`);
}

Теги задаются при fetch-запросах:

const projects = await fetch("/api/projects", {
  next: { tags: ["projects"] },
});

Обработка ошибок

Server Actions не должны выбрасывать ошибки на клиент — это небезопасно (можно слить стек-трейс). Возвращайте результат:

"use server";

type ActionResult<T = void> =
  | { success: true; data?: T }
  | { success: false; error: string };

export async function deleteProject(id: string): Promise<ActionResult> {
  try {
    const project = await prisma.project.findUnique({ where: { id } });

    if (!project) {
      return { success: false, error: "Проект не найден" };
    }

    if (project.status === "IN_PROGRESS") {
      return { success: false, error: "Нельзя удалить активный проект" };
    }

    await prisma.project.delete({ where: { id } });
    revalidatePath("/projects");

    return { success: true };
  } catch (error) {
    console.error("deleteProject failed:", error);
    return { success: false, error: "Ошибка сервера" };
  }
}

Server Actions vs API Routes — когда что

Сценарий Server Actions API Routes
Отправка формы Да Избыточно
CRUD из UI Да Можно
Внешний API (мобильное приложение) Нет Да
Вебхуки от сторонних сервисов Нет Да
Загрузка файлов Да (до 1 МБ по умолчанию) Да (гибче)
Real-time (WebSocket, SSE) Нет Да

Server Actions — для мутаций внутри вашего Next.js-приложения. API Routes — для внешних потребителей и специальных сценариев.

Безопасность

  • Всегда валидируйте входные данные — Server Actions доступны как HTTP-эндпоинты, их можно вызвать напрямую. Zod на каждый action.
  • Проверяйте авторизацию — внутри action проверяйте сессию: const session = await auth(); if (!session) throw new Error("Unauthorized");
  • Не передавайте чувствительные данные — возвращаемое значение action сериализуется и отправляется на клиент. Не возвращайте пароли, токены, внутренние ID.
  • Rate limiting — добавьте ограничение частоты вызовов для форм обратной связи, чтобы не получить спам.

Есть идея? Реализуем

Разрабатываем проекты, которые решают задачи бизнеса — от лендинга до сложного сервиса. Расскажите о своей задаче, подберём решение.

Написать в Telegram

30.03.2026

Нужна консультация?

Оставьте свои контактные данные, или свяжитесь с нами удобным для вас способом

Привет! Меня зовут Багира. Пишите, я все передам хозяевам!

Привет! Меня зовут Багира. Пишите, я все передам хозяевам!

Нажимая кнопку «Принять», вы соглашаетесь на сбор cookie. Мы используем их для обеспечения функционирования веб-сайта, аналитики действий и улучшения качества обслуживания. Если Вы не хотите, чтобы эти данные обрабатывались, отключите cookie в настройках браузера или прекратите использовать сайт.
Принять