lenec ru

← все посты

Route handlers vs Server Actions: что для чего в App Router

13K

В App Router есть два способа сделать серверный эндпоинт: route handler (app/api/.../route.ts) и Server Action ("use server" функция). Оба запускаются на сервере, оба умеют ходить в БД, оба могут читать cookie. Вопрос — что когда брать.

Расскажу, по какому критерию я делю задачи между ними. Без идеологии «Server Actions — это будущее», без «route handlers устарели». Они оба живут и решают разные классы задач.

Что у них общего

  • Запускаются на сервере, имеют доступ к секретам, БД, кешу.
  • Могут читать сессию через cookies().
  • Возвращают данные на клиент.
  • Поддерживают edge и node runtime.

Что у них разное

Route handler — публичный URL

У route handler есть стабильный URL (/api/posts), и он работает как обычный REST-эндпоинт. Любой клиент — твой фронт, мобильное приложение, скрипт, вебхук — может его вызвать.

// app/api/posts/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const posts = await db.posts.findMany();
  return NextResponse.json(posts);
}

export async function POST(req: Request) {
  const data = await req.json();
  const post = await db.posts.create({ data });
  return NextResponse.json(post, { status: 201 });
}

Server Action — RPC-вызов из своего фронта

Server Action — это функция, которую ты вызываешь как обычную с клиента. Под капотом Next.js делает POST на тот же роут с уникальным ID функции. URL у этого вызова есть, но он внутренний и не предназначен для других клиентов.

// app/posts/actions.ts
"use server";

export async function createPost(formData: FormData) {
  const title = formData.get("title");
  if (typeof title !== "string") return { error: "Invalid title" };
  const post = await db.posts.create({ data: { title } });
  return { ok: true, slug: post.slug };
}

Из других клиентов (мобильное приложение, скрипт) ты этого вызвать не сможешь — у тебя нет ни схемы, ни стабильного URL.

Как я выбираю

Главный вопрос: может ли эта функциональность понадобиться кому-то, кроме твоего собственного фронта?

Если да — route handler. Если нет — Server Action.

Когда беру route handler

  • API, который дёргает мобильное приложение или внешний клиент.
  • Webhook от платёжного провайдера, GitHub, Telegram.
  • OAuth-callback или обмен токенами.
  • Streaming-ответ (SSE, чат).
  • Эндпоинт, который вызывается через fetch в TanStack Query.
  • Загрузка файлов с прогрессом.
  • GET-запросы (Server Actions всегда POST).

Когда беру Server Action

  • Простая форма (логин, регистрация, комментарий).
  • Кнопка действия, после которой нужно перерисовать страницу через revalidatePath.
  • Лайк/добавить в избранное/удалить из списка.
  • Любая мутация, которая существует только в контексте конкретной серверной страницы.

Примеры из реальных проектов

Кейс 1: загрузка файла с прогрессом

Server Actions не дают прогресс — у них модель «отправил → получил». Делаю route handler:

// app/api/upload/route.ts
export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get("file") as File;
  const url = await s3Upload(file);
  return Response.json({ url });
}

На клиенте — обычный fetch с XMLHttpRequest для прогресса:

function uploadFile(file: File, onProgress: (n: number) => void) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(e.loaded / e.total);
    };
    xhr.onload = () => resolve(JSON.parse(xhr.responseText));
    xhr.onerror = reject;
    xhr.open("POST", "/api/upload");
    const fd = new FormData();
    fd.append("file", file);
    xhr.send(fd);
  });
}

Кейс 2: лайк к посту

Простое действие, состояние видно только в моём фронте, после клика хочу перерендерить серверный компонент с обновлённым счётчиком. Server Action:

// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";

export async function toggleLike(postId: string) {
  const session = await getSession();
  if (!session) throw new Error("Unauthorized");
  await db.likes.toggle({ postId, userId: session.userId });
  revalidatePath(`/posts/${postId}`);
}
// app/posts/[id]/LikeButton.tsx
"use client";
import { toggleLike } from "../actions";

export function LikeButton({ postId }: { postId: string }) {
  return <button onClick={() => toggleLike(postId)}>Лайк</button>;
}

Никакого fetch, никакого route handler. Чисто, явно.

Кейс 3: webhook от Stripe

Внешний сервис должен дёрнуть мой URL. Только route handler:

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");
  const body = await req.text();
  const event = stripe.webhooks.constructEvent(body, sig!, process.env.WEBHOOK_SECRET!);
  if (event.type === "checkout.session.completed") {
    await processPayment(event.data.object);
  }
  return new Response(null, { status: 200 });
}

Server Action тут вообще нерелевантен — внешний клиент не знает, как с ним общаться.

Кейс 4: эндпоинт для TanStack Query

Если я использую TanStack Query, у меня есть кеш с ключами и инвалидацией. Под него я пишу route handlers, потому что Server Actions с TanStack дружат хуже:

// app/api/users/route.ts
export async function GET() {
  const users = await db.users.findMany();
  return Response.json(users);
}
// app/users/UsersList.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";

export function UsersList() {
  const { data } = useSuspenseQuery({
    queryKey: ["users"],
    queryFn: () => fetch("/api/users").then((r) => r.json()),
  });
  return data.map((u) => <p key={u.id}>{u.name}</p>);
}

Когда комбинирую

На многих проектах у меня обе механики работают одновременно. Нет правила «либо одно, либо другое». Простые формы — Server Actions, сложные мутации с прогрессом и оптимистичными апдейтами через TanStack Query — route handlers.

Иногда даже одна и та же логика дублируется. Например, у меня есть db.posts.create в Server Action для формы поста и в route handler для мобильного приложения. Не идеально, но иногда правильно: сценарии разные, требования разные, ошибки обрабатываются по-разному.

Чтобы не дублировать саму логику, выношу её в общий сервис:

// lib/services/posts.ts
export async function createPostService(data: CreatePostInput, userId: string) {
  // вся бизнес-логика тут
  return db.posts.create({ data: { ...data, userId } });
}

А Server Action и route handler — это просто два «фронта» к этому сервису.

Грабли

1. Server Action на GET-сценарий

Server Actions всегда POST. Нельзя сделать «Server Action для получения данных» — для этого есть серверные компоненты с прямым fetch или route handler.

2. Слишком много логики в route handler

Route handler — это «контроллер». Он должен распарсить запрос, дёрнуть сервис, вернуть ответ. Если внутри handler-а двести строк бизнес-логики — выноси в сервис.

3. CORS на route handler

По умолчанию route handler в Next.js не отдаёт CORS-заголовков. Если внешний клиент должен дёргать с другого домена — добавляй заголовки явно или используй middleware/настройки headers.

4. Server Action из несвоего фронта

Не пытайся вызвать Server Action из React Native через какой-то URL. Не получится. Если нужно — переноси логику в route handler.

Что запомнить

Route handler — публичный URL для всех клиентов. Server Action — RPC из своего фронта. Один критерий: будет ли вызывать кто-то ещё, кроме твоего собственного фронта. Если да — route handler. Если нет — Server Action.

В реальных проектах оба сосуществуют. Не пытайся переписать всё на одно. Лучше выноси бизнес-логику в сервисы, а Server Action и route handler оставь как тонкие обёртки сверху.

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

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

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