Server Actions в Next 15: когда не использовать
Server actions в Next 15 — это удобно. Но удобно настолько, что начинаешь применять их везде подряд, и через месяц у тебя в проекте 200 серверных функций, которые делают всё подряд: от мутаций до агрегаций. Я недавно разгребал такой проект и заодно сформулировал для себя ситуации, когда server actions действительно подходят, и когда лучше остановиться.
Что такое server action
Это функция с директивой 'use server', которая компилируется в RPC-эндпоинт. Ты дёргаешь её из формы или из клиентского компонента, а Next автоматически делает запрос на сервер, отдаёт ответ и (при желании) ревалидирует кеш.
// app/posts/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}// app/posts/page.tsx
import { deletePost } from './actions';
<form action={async () => { 'use server'; await deletePost(post.id); }}>
<button>Удалить</button>
</form>Никаких fetch, никаких ручных эндпоинтов, никакого ручного управления состоянием формы. Прекрасно — ровно до момента, когда сценарий перестаёт быть простой формой.
Когда server action — оптимальный выбор
Это сценарии, под которые server actions и заточены: мутации, прикреплённые к UI, без сложной клиентской логики.
- Лайк / дизлайк, подписка / отписка.
- Создание комментария.
- Простая форма с валидацией: имя, email, отправка.
- Удаление элемента из списка.
- Простая форма авторизации.
В таких случаях у тебя одна функция, она вызывается один раз на действие пользователя, и тебе не нужны промежуточные состояния «загружается», «ошибка», «частично готов» с тонкой обработкой.
Когда я не использую server actions
Это уже опыт, набитый на проде. У меня есть 4 категории сценариев, в которых server actions либо неудобны, либо плохи концептуально.
1. Сложная клиентская валидация и UX-флоу
Если форма имеет несколько шагов, валидация на каждом из них и нужно отображать частично собранные данные — server actions становятся костылём. Тут нужен полноценный клиентский менеджмент состояния (TanStack Query или собственный store) и обычный API.
На server action ты получаешь либо успех, либо ошибку. Промежуточные состояния «отправил часть, ждём, добавляем шаг 2» нормально не складываются. Я однажды попытался реализовать многошаговый wizard через server actions — закончилось переписыванием на классический REST с локальным store.
2. Высокочастотные операции
Server actions — это HTTP-запросы. Если у тебя в фиче «лайк» жмут по 5 раз в секунду, ты будешь забивать сеть. Тут лучше дебаунсить на клиенте, отправлять пакетом, либо использовать WebSocket. Server action для каждого тика — анти-паттерн.
3. Долгие операции
Server action возвращает результат целиком, без стриминга. Если задача — выгрузить отчёт на 5 МБ или сгенерировать PDF — пользователь будет смотреть в spinner десятки секунд. Это плохой UX и плохое использование инфраструктуры.
Для длинных задач: классический endpoint + очередь, polling статуса или WebSocket. Server action подходит только как «начни задачу», не как «жди готовый PDF в потоке».
4. Внешние интеграции с особыми требованиями
Если тебе нужно отдавать ровно application/xml с конкретной схемой, ставить специфические заголовки, поддерживать CORS для сторонних клиентов — это API. Server action к этому не приспособлен: он использует встроенный механизм с собственным форматом запроса/ответа.
Безопасность
Большая ловушка: server actions доступны клиенту, даже если их формально «не показывают» в UI. Каждый action — это endpoint, который кто угодно может вызвать. Поэтому внутри обязательно проверяй права:
'use server';
import { auth } from '@/lib/auth';
export async function deletePost(id: string) {
const session = await auth();
if (!session) throw new Error('Unauthorized');
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== session.userId) throw new Error('Forbidden');
await db.post.delete({ where: { id } });
}Не полагайся на «эту кнопку видит только админ» — кнопки нет, но action вызывается. Я видел проект, где админ-action удалял что угодно по id, и любой залогиненный мог стереть чужие посты, если узнавал id.
Оптимистичные обновления
В UX-сценариях часто хочется, чтобы UI обновился сразу, а сервер догонял в фоне. Для этого React даёт хук useOptimistic:
'use client';
import { useOptimistic } from 'react';
import { addLike } from './actions';
export function LikeButton({ postId, count }: { postId: string; count: number }) {
const [optimistic, setOptimistic] = useOptimistic(count, (state, n: number) => state + n);
return (
<form action={async () => {
setOptimistic(1);
await addLike(postId);
}}>
<button type="submit">❤️ {optimistic}</button>
</form>
);
}Тут UI меняется мгновенно, а server action идёт в фоне. Если он упадёт — состояние откатится. Это рабочий паттерн, но именно с ним легко напороться: у меня были случаи, когда action падал, а UI оставался в «увеличенном» виде, потому что мы не обработали ошибку. Документация местами недосказана — лучше тестировать оба пути.
Кеширование и ревалидация
Server action часто меняет данные. Чтобы UI это увидел, нужен либо revalidatePath('/posts'), либо revalidateTag('posts'). Без этого закешированный fetch вернёт старое.
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(data: FormData) {
const post = await db.post.create({ data: { title: data.get('title')?.toString() ?? '' } });
revalidateTag('posts');
return post;
}На запросах: fetch('/api/posts', { next: { tags: ['posts'] } }). Так Next понимает, какой кеш нужно сбросить.
Ошибки и обратная связь
Server action не имеет встроенного формата для возврата ошибок. Если бросишь exception — попадёт в error boundary, что часто избыточно. Для UX-валидации лучше возвращать структуру:
'use server';
import { z } from 'zod';
export async function submitContact(data: FormData) {
const schema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
const parsed = schema.safeParse({ email: data.get('email'), message: data.get('message') });
if (!parsed.success) {
return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
}
await sendEmail(parsed.data);
return { ok: true as const };
}На клиенте через useActionState читаешь это значение и подсвечиваешь поля. Кода становится больше, но UX контролируется.
В сухом остатке
- Server action идеально подходит для простых мутаций «один клик — одно действие».
- Не пихай в server actions сложные многошаговые формы — это путь в боль.
- Каждый action — endpoint. Безопасность проверяй внутри.
- Для длинных операций используй очередь, не action.
- Не забывай
revalidatePath/revalidateTag.
В проекте, где server actions заняли свою нишу (формы, лайки, простые мутации), а сложная логика осталась в API, всё ощущается легко. Когда server actions начинают делать всё — теряется и предсказуемость, и производительность. Поставь себе границу с самого начала.