В традиционной клиент-серверной веб-разработке выполнение мутаций данных требовало создания избыточного инфраструктурного кода. Разработчику приходилось создавать отдельный REST/GraphQL эндпоинт, вручную настраивать клиентские обработчики onSubmit, конфигурировать HTTP-клиент, отслеживать состояния загрузки через useState, обрабатывать сетевые ошибки и запускать ручную инвалидацию клиентских кэшей.
В Next.js App Router и React 19 реализована концепция Server Actions — асинхронных серверных функций, вызываемых напрямую из клиентских компонентов или передаваемых в атрибут action HTML-форм.
Преимущества архитектуры Server Actions:
revalidatePath/revalidateTag) и повторный рендеринг серверных компонентов возвращаются клиенту в рамках одного HTTP-ответа.В основе Server Actions лежит директива 'use server'. Она инструктирует компилятор Next.js (Turbopack) о том, что данная функция или модуль должны исполняться исключительно на сервере.
Когда Server Action передается в клиентский компонент, компилятор не включает тело функции в клиентский JavaScript-бандл. Вместо этого генерируется уникальный криптографический идентификатор (Action ID), а на клиенте создается легковесный прокси-указатель (Reference).
При вызове функции браузер прозрачно отправляет HTTP POST-запрос с кастомным заголовком Next-Action. Сервер исполняет бизнес-логику и возвращает сериализованный ответ.
Концептуально Server Actions представляют собой реализацию архитектуры RPC, бесшовно связанную с деревом компонентов React.
Пользователь отправляет форму или вызывает action программно через transition.
При наличии JS вызывается синтетическое событие; без JS отправляется стандартный нативный POST-запрос формы.
Браузер отправляет POST-запрос с телом FormData и уникальным Action ID в заголовке.
Next.js валидирует домен запроса для автоматического предотвращения CSRF-атак.
Если заголовки Origin и Host расходятся, сервер немедленно возвращает 403 Forbidden.
Сервер расшифровывает замкнутые параметры .bind(), проверяет сессию и выполняет SQL-запрос.
Вызов revalidatePath сбрасывает кэш и тут же запускает повторный рендеринг затронутых RSC.
Клиент получает результат действия и свежий RSC Flight Payload в теле одного ответа.
React плавно мерджит обновленные серверные компоненты в DOM без перезагрузки страницы.
Server Actions можно объявлять двумя способами:
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { revalidatePath } from 'next/cache';
export default function PostsPage() {
// Асинхронная функция внутри Server Component
async function createPost(formData: FormData): Promise<void> {
'use server'; // Директива в начале тела функции объявляет ее Server Action
const title = formData.get('title');
if (typeof title !== 'string' || !title.trim()) {
return;
}
await db.insert(posts).values({ title });
// Инвалидация кэша для немедленного отображения нового поста
revalidatePath('/posts');
}
return (
<main className="p-6">
<form action={createPost} className="flex gap-2">
<input type="text" name="title" required className="border p-2 rounded" />
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Создать пост
</button>
</form>
</main>
);
}Для использования действий внутри Client Components ('use client') они обязаны быть вынесены в отдельный модуль с директивой 'use server' в начале файла:
'use server'; // Применяется ко всем экспортируемым функциям модуля
import { db } from '@/lib/db';
import { posts } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
export async function deletePostAction(postId: string): Promise<{ success: boolean }> {
await db.delete(posts).where(eq(posts.id, postId));
revalidatePath('/posts');
return { success: true };
}Для управления состоянием формы, обработки валидационных ошибок и отображения статуса ожидания в React 19 используется хук useActionState.
'use client';
import { useActionState } from 'react';
import { createUserAction, type FormState } from '@/app/actions/users';
const initialState: FormState = {
success: false,
message: '',
errors: undefined,
};
export function UserForm() {
// state — текущее состояние, возвращенное экшеном
// formAction — прокси-функция для передачи в <form action={...}>
// isPending — флаг асинхронного выполнения запроса
const [state, formAction, isPending] = useActionState(createUserAction, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded p-2"
/>
{state.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? 'Сохранение...' : 'Зарегистрировать'}
</button>
{state.message && (
<p className={`text-sm ${state.success ? 'text-green-600' : 'text-red-600'}`}>
{state.message}
</p>
)}
</form>
);
}Хук useFormStatus из пакета react-dom позволяет дочерним компонентам читать контекст статуса родительской формы (например, для блокировки кнопки отправки).
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label = 'Отправить' }: { label?: string }) {
// Хук читает контекст ближайшей родительской формы <form>
const { pending, data, method } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 rounded bg-accent-cyan text-black font-semibold disabled:opacity-50 transition-opacity"
>
{pending ? 'Обработка запроса...' : label}
</button>
);
}Хук useOptimistic позволяет мгновенно отразить изменения в интерфейсе до завершения сетевого запроса к серверу:
'use client';
import { useOptimistic, startTransition } from 'react';
import { likePostAction } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentLikes: number, increment: number) => currentLikes + increment
);
const handleLike = () => {
startTransition(async () => {
// 1. Мгновенно обновляем интерфейс на клиенте
addOptimisticLike(1);
// 2. Отправляем запрос на сервер
await likePostAction(postId);
});
};
return (
<button
type="button"
onClick={handleLike}
className="border border-border px-3 py-1.5 rounded-lg hover:border-accent-cyan"
>
Нравится: {optimisticLikes}
</button>
);
}Почему вызов функции redirect('/dashboard') внутри блока try { ... redirect('/dashboard') } catch (err) { ... } в Server Action приводит к ошибке выполнения?
Форма при сабмите передает в Server Action только объект FormData. Если действию требуется передать дополнительный контекстный аргумент (например, id записи), используется метод .bind().
'use client';
import { deleteUserAction } from '@/app/actions/users';
interface UserDTO {
id: string;
name: string;
}
interface UserListProps {
user: UserDTO;
}
export function UserItem({ user }: UserListProps) {
// Привязываем аргумент userId первым параметром Server Action
const deleteWithUserId = deleteUserAction.bind(null, user.id);
return (
<div className="flex items-center justify-between p-3 border rounded">
<span>{user.name}</span>
<form action={deleteWithUserId}>
<button type="submit" className="text-red-500 hover:underline">
Удалить
</button>
</form>
</div>
);
}[!IMPORTANT] Server Actions — это открытые публичные RPC-эндпоинты. Каждый экспортированный action доступен для вызова прямым HTTP POST-запросом из любого инструмента (cURL, Postman). Наличие action внутри закрытого клиентского интерфейса не защищает его от несанкционированного вызова. Проверка сессии и прав (RBAC) строго обязательна в первой строке каждого Server Action.
'use server';
import { cookies } from 'next/headers';
import { verifySessionToken } from '@/lib/auth';
export async function executeAdminTask(data: FormData): Promise<{ success: boolean; error?: string }> {
// Асинхронное чтение cookies в соответствии со стандартом Next.js 15+
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return { success: false, error: 'Unauthorized: Сессия отсутствует' };
}
const session = await verifySessionToken(token);
if (!session || session.role !== 'admin') {
return { success: false, error: 'Forbidden: Недостаточно прав доступа' };
}
// Бизнес-логика задачи администратора...
return { success: true };
}Серверный компонент загружает из БД объект пользователя со 150 полями (включая passwordHash). Внутри компонента объявлен инлайн Server Action: async function update() { 'use server'; await db.save(user.id); }. В чем заключается угроза безопасности?
Функция redirect() из next/navigation намеренно выбрасывает специальное служебное исключение React/Next.js (NEXT_REDIRECT), чтобы прервать текущий рендеринг и отдать клиенту заголовок перенаправления.
Если обернуть вызов redirect() в общий блок try/catch, это исключение будет ошибочно перехвачено как пользовательская ошибка, и редирект не сработает.
Решение: Всегда вызывайте redirect() после блока try/catch.
// ❌ ОШИБКА: redirect перехвачен блоком catch
'use server';
import { redirect } from 'next/navigation';
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
redirect('/posts'); // Выбрасывает исключение NEXT_REDIRECT!
} catch (err: unknown) {
// Редирект пойман здесь, пользователь видит ошибку вместо перехода!
return { error: 'Не удалось создать пост' };
}
}
// ✅ ПРАВИЛЬНО: Вызывать redirect ПОСЛЕ блока try/catch
export async function createPostAction(prevState: unknown, formData: FormData) {
try {
await db.insert(posts).values({ title: formData.get('title') });
} catch (err: unknown) {
return { error: 'Не удалось создать пост' };
}
redirect('/posts'); // Выполняется успешно вне блока перехвата
}Server Action, определенный с директивой 'use server', доступен абсолютно любому клиенту, знающему его ID (хэш функции). Даже если кнопка удаления скрыта в UI для обычных пользователей через условный рендеринг, злоумышленник может отправить прямой HTTP POST-запрос с заголовком Next-Action и удалить данные.
Решение: Авторизация и валидация прав доступа (RBAC) должны проводиться внутри каждого действия на сервере перед выполнением любой бизнес-логики.
Когда Server Action создается как инлайн-функция внутри серверного компонента, компилятор Next.js шифрует и отправляет клиенту все переменные, захваченные из области видимости родительского компонента.
Решение: Выносите экшены в отдельные модульные файлы actions.ts и передавайте только нужные примитивы через .bind().
// ❌ ОПАСНО: В action захвачен весь объект heavyUser с приватными данными
export default async function ProfilePage() {
const heavyUser = await db.getUser(); // Содержит passwordHash, secretKeys, 100 полей
async function updateStatus() {
'use server';
// Хотя используется только id, Next.js зашифрует и передаст на клиент ВЕСЬ heavyUser!
await db.updateUserStatus(heavyUser.id, 'online');
}
return <form action={updateStatus}><button>Online</button></form>;
}
// ✅ ПРАВИЛЬНО: Выносите экшены в actions.ts и передавайте только скалярные ID
// app/actions.ts:
// export async function updateStatus(userId: string) { 'use server'; ... }