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 — добавьте ограничение частоты вызовов для форм обратной связи, чтобы не получить спам.
Есть идея? Реализуем
Разрабатываем проекты, которые решают задачи бизнеса — от лендинга до сложного сервиса. Расскажите о своей задаче, подберём решение.

