Server Actions в Next.js: где они уместны, а где лучше REST
Server Actions преподносят как «теперь не нужно писать API-эндпоинты». Это правда ровно настолько же, насколько и неправда. На простых формах действительно не нужно. На всём остальном — нужно, просто называется иначе и пишется по-другому.
Год назад мы переписали половину админки на Server Actions, потом откатили примерно треть назад на классические REST-роуты. Не потому что инструмент плохой, а потому что он закрывает не все сценарии. Расскажу, какие границы у меня сложились на практике.
Как Server Actions работают по факту
Когда ты пишешь функцию с "use server" в начале и вызываешь её с клиента, Next.js под капотом делает следующее: на сервере регистрирует эту функцию по уникальному ID, на клиенте генерирует proxy, который при вызове отправляет POST-запрос на тот же роут с этим ID и сериализованными аргументами. Результат возвращается обратно. Это не магия — это RPC поверх HTTP с сериализацией.
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.length === 0) {
return { error: "Title required" };
}
await db.posts.create({ data: { title } });
revalidatePath("/posts");
return { ok: true };
}
Главное, что отсюда стоит запомнить: Server Action — это всегда POST. Всегда. На GET-запросы это не подходит концептуально. Это сразу обрезает половину сценариев.
Где Server Actions реально хороши
Простые формы с прогрессивным улучшением
Тот сценарий, ради которого их и придумали. Форма входа, форма комментария, простой CRUD. Пишешь action, вешаешь на action-проп формы, и всё работает даже без JS на клиенте — браузер сделает обычный POST.
// app/login/page.tsx
import { login } from "./actions";
export default function LoginPage() {
return (
<form action={login}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Войти</button>
</form>
);
}
На таких сценариях Server Actions уделывают любой REST-подход, потому что не нужен fetch, не нужен loading-стейт, не нужен handler с preventDefault. Я их использую почти везде, где форма умещается в три-четыре поля.
Мутации с инвалидацией кеша Next.js
Server Action отлично интегрируется с revalidatePath и revalidateTag. Сделал мутацию, дёрнул revalidate, серверные компоненты на странице автоматически перерендерились с новыми данными. Без этой связки пришлось бы вручную дёргать обновление через клиентский стейт-менеджер.
Защищённые операции с сессией
В Server Action ты на сервере, у тебя есть прямой доступ к cookie, базе, env-переменным. Это удобно, когда логика требует проверки сессии и пары запросов в БД. Эквивалент REST-эндпоинта, но без отдельного файла-роута.
Где Server Actions ломаются на реальных задачах
Сложный UI-стейт во время отправки
Это самая частая проблема, и она техническая, а не идеологическая. Когда форма сложнее логина — нужно показать спиннер на конкретном поле, обновить три разных списка после сабмита, проиграть анимацию успеха — Server Actions начинают мешать.
API useActionState и useFormStatus предполагают плоскую схему: «отправил → получил результат → отрисовал». Шаги «во время отправки покажи прогресс загрузки файла», «после первой части ответа уже сделай вот это» — за пределами этой схемы.
"use client";
import { useActionState } from "react";
import { uploadFile } from "./actions";
export function UploadForm() {
const [state, action, pending] = useActionState(uploadFile, {});
// pending — это true пока action работает.
// Никакого прогресса. Никакой возможности отменить.
return (
<form action={action}>
<input type="file" name="file" />
<button disabled={pending}>Загрузить</button>
</form>
);
}
На загрузке файлов с прогресс-баром мы откатились на обычный fetch с XMLHttpRequest, потому что прогресс через Server Actions недоступен. Это не баг — это архитектурное ограничение.
Сложные оптимистичные апдейты
API useOptimistic справляется с простыми случаями. Но как только тебе нужны откаты с восстановлением промежуточного стейта, очередь оптимистичных операций, или хитрые мерджи — становится тяжело.
В библиотеках типа TanStack Query это всё уже решено: есть onMutate, onError, onSettled, есть глобальный кеш с возможностью откатить любую мутацию. Server Actions подобного механизма не имеют — они подразумевают «доверь это серверу и Next.js».
Внешние клиенты, не браузер
Server Action — это не публичный API. У него нет стабильного URL, нет понятной схемы, нет документации. Если завтра тебе нужно к этой логике обратиться из мобильного приложения, скрипта на Python или вебхука — ты сядешь и напишешь нормальный REST или RPC.
Я для себя завела правило: если функция нужна только из своего фронта — Server Action; если может понадобиться кому-то ещё — отдельный API-роут.
Тяжёлая обработка с прогрессом
Любой долгий процесс — формирование отчёта на 10 минут, импорт CSV в 100К строк — не помещается в схему Server Actions. Тебе всё равно нужна очередь задач, статус-эндпоинт, опрос или WebSocket. Server Action тут только запускает процесс, остальное вне его.
Типичные ошибки, на которые я наступала
1. Закрытие в типах
"use server";
export async function deleteUser(id: string) {
await db.users.delete({ where: { id } });
}
Кажется, что id приходит с клиента и его можно тут же отдать в БД. Нет. Любой клиент может вызвать твой Server Action с любым id. Проверка прав — твоя ответственность, в каждом действии. Никаких middleware, защищающих сразу всю папку, как было в API Routes.
"use server";
import { getSession } from "./auth";
export async function deleteUser(id: string) {
const session = await getSession();
if (!session?.isAdmin) {
throw new Error("Forbidden");
}
await db.users.delete({ where: { id } });
}
2. Не сериализуемые аргументы
Передать в Server Action можно только сериализуемые штуки: примитивы, объекты из примитивов, массивы, FormData, Date, простые объекты. Передать функцию, класс, инстанс с методами — нельзя. Компилятор иногда это пропускает, рантайм ругается.
3. Гонки при быстром нажатии
Если пользователь быстро кликает кнопку, Server Actions ставятся в очередь, но клиентский стейт обновляется неконтролируемо. Я обычно дизейблю кнопку через useFormStatus или флаг pending из useActionState. Без этого получишь дублирующиеся записи в БД.
4. Ошибки видны не там, где думаешь
Throw из Server Action в production по умолчанию приходит на клиент как обобщённая ошибка без деталей (это правильно — не нужно палить внутренности). Но при дебаге это путает. Я возвращаю ошибки как часть результата, а не через throw:
"use server";
export async function createPost(input: FormData) {
const result = postSchema.safeParse({
title: input.get("title"),
});
if (!result.success) {
return { ok: false as const, error: "Validation failed" };
}
await db.posts.create({ data: result.data });
return { ok: true as const };
}
Так клиент получает типизированный объект, а не пытается перехватить throw в каких-то странных местах.
Когда я выбираю REST вместо Server Actions
Простые правила, по которым я разрезаю проект:
- Нужен GET — REST.
- Нужен прогресс или streaming — REST с SSE/WebSocket.
- Будет вызывать кто-то кроме своего фронта — REST.
- Сложный клиентский стейт с откатами — REST + TanStack Query.
- Простая форма-мутация — Server Action.
- Кнопка «лайк/удалить/добавить в избранное» — Server Action.
- Серверный компонент дёргает данные — это не Server Action, это просто
async-функция.
Что запомнить
Server Actions — удобный сахар над POST-эндпоинтами для сценариев «форма → мутация → инвалидация кеша». В этих сценариях они экономят время и убирают шаблонный код. Вне этих сценариев они либо не работают (GET, прогресс, внешние клиенты), либо работают со скрипом (сложный стейт, оптимистика).
Не пытайся переписать весь бэкенд на Server Actions ради того, чтобы «не было API-роутов». API-роуты никуда не делись и нужны не реже, чем раньше. Просто теперь у тебя есть второй инструмент для конкретного класса задач, и хорошая архитектура — та, где они оба используются по назначению.