lenec ru

← все посты

Server Actions в Next 15: когда не использовать

11K

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 начинают делать всё — теряется и предсказуемость, и производительность. Поставь себе границу с самого начала.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.