Route handlers vs Server Actions: что для чего в App Router
В 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 оставь как тонкие обёртки сверху.