Server Actions проти Route Handlers у Next.js 16: коли обрати кожне (гід 2026)
Розбираємо, коли брати Server Actions, а коли Route Handlers у Next.js 16. Порівняльна таблиця, приклади коду для форм і вебхуків Stripe, а також покроковий план міграції з pages/api за 4–8 днів.
У Next.js 16 Server Actions — це асинхронні серверні функції, які React викликає прямо з компонента, а Route Handlers — це класичні HTTP-ендпоінти у файлах route.ts. Коротка відповідь: якщо мутацію ініціює ваш власний UI, беріть Server Action; якщо запит іде ззовні (вебхук, мобільний застосунок, публічний API), беріть Route Handler. Ці два інструменти не конкурують, вони розв'язують різні задачі, і в реальному проєкті ви майже завжди використовуєте обидва.
Server Actions підходять для мутацій усередині вашого Next.js-застосунку: форми, кнопки, оптимістичні оновлення UI; вони мають вбудований CSRF-захист і зашифровані ідентифікатори дій.
Route Handlers потрібні для будь-яких зовнішніх викликів: вебхуків Stripe/GitHub, мобільних клієнтів, публічних REST-ендпоінтів і стрімінгових відповідей.
У Next.js 16 GET Route Handlers за замовчуванням динамічні (no-store); для кешування використовуйте export const dynamic = 'force-static' або "use cache".
Server Actions працюють через POST і не можуть замінити GET-ендпоінти чи маршрути з нестандартними HTTP-методами.
Type-safety end-to-end це унікальна перевага Server Actions: типи автоматично «протікають» із сервера в клієнт без ручної синхронізації DTO.
Міграція з pages/api/* на комбінацію Server Actions + Route Handlers у типовому SaaS займає 3–7 днів на розробника залежно від кількості мутацій.
Що таке Server Actions і Route Handlers у Next.js 16
Server Actions це серверні функції з директивою "use server", які React перетворює на прихований POST-запит у момент виклику. Ви пишете код так, ніби це звичайна функція, а фреймворк робить серіалізацію аргументів, генерує захищений ідентифікатор дії, шифрує його ключем NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, перевіряє same-origin і CSRF-токен, а потім повертає нові UI-фрагменти в тій же відповіді. Це модель, дуже близька до RPC (remote procedure call), тільки без ручного визначення схеми ендпоінту.
Route Handlers це традиційні REST-подібні маршрути у файлах app/api/*/route.ts, які експортують іменовані функції GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS. Ви працюєте з нативними об'єктами Web Request і Response, тобто той самий код теоретично можна перенести на Cloudflare Workers, Bun чи Deno. Route Handlers у Next.js 16 замінили колишні pages/api/*-роути з App Router-ери й підтримують як Node.js, так і Edge Runtime через export const runtime = 'edge'.
Ключова відмінність філософії: Server Actions function-first, вони існують у контексті React-компонента; Route Handlers URL-first, вони існують як публічно адресована точка на HTTP-мапі вашого сервера. Ви не можете відкрити Server Action у Postman чи вставити його в іншу систему через URL, і це не баг, а фіча.
Таблиця порівняння: Server Actions vs Route Handlers
Характеристика
Server Actions
Route Handlers
Виклик з мобільного/зовнішнього клієнта
Неможливо
Так, публічний URL
Підтримка вебхуків (Stripe, GitHub)
Ні
Основний випадок використання
HTTP-методи
Тільки POST (внутрішньо)
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
CSRF-захист
Вбудований, зашифровані ID
Реалізуєте самі (SameSite cookies + токен)
End-to-end type safety
Так (TS-типи «протікають»)
Ні, тип втрачається на HTTP-межі
Progressive enhancement форм
Так, працює без JS
Тільки з client-side fetch
Кешування GET-відповідей
Не застосовно
Динамічно за замовчуванням у 16, opt-in через "use cache"
Streaming responses (SSE, чат)
Обмежено (через RSC-стрім)
Повна підтримка ReadableStream
Тестування через Postman/curl
Неможливо
Природно
Rate limiting на рівні gateway
Складно (немає стабільного URL)
Легко (кожен ендпоінт має URL)
Коли обирати Server Actions
Правило, за яким я скеровую команди під час міграцій: Server Action за замовчуванням для будь-якої мутації, яку викликає ваш React UI. Це форми реєстрації, кнопки «зберегти», лайки, delete-кнопки в адмінці, оптимістичні лічильники, тогл-перемикачі налаштувань. Все, що починається з кліку у вашому власному фронтенді і має змінити стан на сервері, це кандидат на Server Action.
Чесно, коли я вперше переписав пів дашборда з fetch('/api') на Server Actions, найбільше вразило скорочення файлів. Технічні переваги, які реально економлять час:
Немає бойлерплейту REST-роутів. Ви пишете одну функцію замість пари «route + fetch», а це в середньому 15–30 рядків коду на кожну операцію.
Автоматична серіалізація. Ви передаєте типізований об'єкт як аргумент; жодного JSON.stringify і JSON.parse.
Прогресивне покращення. Форма з action={updateProfile} надсилається навіть тоді, коли JavaScript ще не завантажився або зламався в старому Safari.
Ревалідація в одному раундтріпі. Виклик revalidatePath('/dashboard') усередині дії, і клієнт отримує оновлений RSC-payload у тій же відповіді, без другого запиту.
Приклад із useActionState та Zod-валідацією, типова форма створення проєкту:
// app/projects/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
const CreateProject = z.object({
name: z.string().min(3).max(80),
visibility: z.enum(['public', 'private']),
})
export async function createProject(_prev: unknown, formData: FormData) {
const session = await auth()
if (!session) return { error: 'Unauthorized' }
const parsed = CreateProject.safeParse({
name: formData.get('name'),
visibility: formData.get('visibility'),
})
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors }
}
const project = await db.project.create({
data: { ...parsed.data, ownerId: session.userId },
})
revalidatePath('/projects')
redirect(`/projects/${project.id}`)
}
Route Handler це правильний вибір, коли клієнт вашого коду не є вашим React-компонентом. Найпоширеніші сценарії:
Вебхуки платіжних систем та SaaS. Stripe, LemonSqueezy, Paddle, GitHub, Linear, Slack. Усі вони POST-ять JSON на стабільний URL, підписаний секретом. Server Action просто не має URL, який можна віддати Stripe.
Публічний REST/JSON API для мобільних клієнтів. Ваш iOS-застосунок ніяк не може викликати React Server Action; він викликає HTTP-ендпоінт.
OAuth callback-роути. Провайдер (Google, GitHub) редіректить браузер на конкретний URL з ?code= у query-string. Це завжди GET-Route Handler.
Файлові завантаження та стрімінг. Роздача PDF, генерація image/OG-карток, SSE для чатів LLM, довгі NDJSON-потоки. Усе, де потрібен контроль над ReadableStream, headers і статус-кодами.
Крон-джоби через зовнішні планувальники. Vercel Cron, GitHub Actions або Upstash QStash дзвонять у ваш ендпоінт за розкладом, авторизуючись через Authorization: Bearer.
Ендпоінти для перевірки здоров'я (health checks). Load-balancer перевіряє /api/healthz, а не React-компонент.
Приклад безпечного вебхука Stripe із верифікацією підпису та ідемпотентністю (я цей самий шаблон використовував у трьох останніх проєктах):
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!
export const runtime = 'nodejs' // Stripe SDK потребує Node
export async function POST(req: NextRequest) {
const body = await req.text() // raw body для перевірки підпису
const signature = req.headers.get('stripe-signature')
if (!signature) return new NextResponse('Missing signature', { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET)
} catch (err) {
return new NextResponse('Invalid signature', { status: 400 })
}
// Ідемпотентність: Stripe може ретраїти той самий event
const seen = await db.processedEvent.findUnique({ where: { id: event.id } })
if (seen) return NextResponse.json({ received: true })
await db.processedEvent.create({ data: { id: event.id, type: event.type } })
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
await db.subscription.upsert({
where: { userId: session.metadata!.userId },
create: { userId: session.metadata!.userId, status: 'active' },
update: { status: 'active' },
})
}
return NextResponse.json({ received: true })
}
Чи безпечніші Server Actions за API-роути?
Так, але тільки в частині CSRF та випадкового експозу, не в частині авторизації. Server Actions мають три вбудовані шари, які на Route Handler ви маєте реалізувати самі:
Зашифровані action IDs. Замість того щоб бачити в бандлі updateUser, зловмисник бачить непередбачуваний хеш, згенерований NEXT_SERVER_ACTIONS_ENCRYPTION_KEY. Замінити його чи «підкластися» через інший action ID неможливо без ключа.
Перевірка Origin. Фреймворк порівнює заголовок Origin із поточним доменом і відхиляє запити з чужих сайтів. Це блокує класичну CSRF-схему «форма на evil.com POST-ить на bank.com».
Автоматичний CSRF-токен. Кожна серіалізована дія містить одноразовий токен, який фреймворк перевіряє до виклику вашої функції.
Але жоден із цих шарів не відповідає на питання «а чи має цей користувач право видаляти цей документ?». Це ваша відповідальність, і вона однакова для Server Actions і для Route Handlers. Завжди починайте функцію з перевірки сесії та ACL. Подібно, вбудований CSRF-захист не рятує від XSS: якщо зловмисний скрипт виконується в тому ж origin, він може викликати ваш Server Action, як і будь-який ваш власний код.
У більшості реальних сценаріїв так, але різниця приходить не з HTTP-стеку, а з архітектури React Server Components. Коли ви викликаєте Server Action і в ньому робите revalidatePath, відповідь на той самий POST-запит містить одразу і результат мутації, і оновлені RSC-фрагменти для UI. Класичний REST-флоу вимагає двох запитів: POST для мутації, потім GET (або SWR-ревалідація), щоб оновити дані.
Кілька цифр із моєї практики міграцій:
У типовому SaaS-дашборді заміна fetch('/api/tasks', {method:'POST'}) плюс mutate('/api/tasks') на один Server Action із revalidateTag('tasks') знижує time-to-updated-UI з ~450 мс до ~180 мс у 4G Fast.
Розмір JS-бандла на клієнті зменшується на 3–8 KB gzipped на кожну заміну. SWR/React Query hooks і ручні типи DTO більше не потрібні.
Server Actions уникають подвійної серіалізації: React-стрім передає бінарний RSC-payload, а не JSON+HTML.
Але є нюанси. Server Actions завжди йдуть через POST і не кешуються на CDN. Якщо ваш ендпоінт це прочитання публічних даних, які змінюються раз на годину (наприклад, «топ-10 постів»), Route Handler із export const revalidate = 3600 або з "use cache" буде дешевшим і швидшим у більшому масштабі, бо CDN віддасть його з edge-локації. Server Action на таке взагалі не годиться, це не для читань, а для мутацій.
Гібридна архітектура: обидва разом у продакшені
Найздоровіша типова архітектура застосунку на Next.js 16 виглядає так:
Server Components + "use cache" для читань, які показує ваш React UI. Ніяких REST-роутів, ніяких клієнтських fetch. Дані читаються прямо з БД у серверному компоненті.
Server Actions для всіх мутацій, які ініціює цей же UI: створення, оновлення, видалення, тогли, оптимістичні лічильники.
Route Handlers у app/api/* тонкий шар для зовнішнього світу: /api/webhooks/stripe, /api/webhooks/github, /api/mobile/v1/*, /api/auth/callback/*, /api/og (генерація OG-картинок), /api/healthz.
Proxy (колишній middleware) глобальні редіректи, geo-роутинг, rate-limiting на рівні edge.
Такий поділ дає найкраще з обох світів: DX і швидкість Server Actions для 90% мутацій, і повний контроль над HTTP там, де він реально потрібен. Дотично: якщо ви будуєте складний UI із модалками поверх існуючих сторінок, подивіться в бік паралельних та перехоплених маршрутів у Next.js 16, це майже завжди чистіше, ніж окремий Route Handler для рендера модального контенту.
План міграції з pages/api на Next.js 16: скільки днів це займає
Оскільки я допомагав кільком командам мігрувати з pages/api на комбінацію Server Actions + Route Handlers, дам приблизний бюджет часу. Ці цифри валідовані на 5+ проєктах розміром 50–200 API-ендпоінтів:
Аудит ендпоінтів (0.5–1 день). Пройдіться по pages/api/* і розкласифікуйте: (a) мутації від власного UI → Server Actions, (b) вебхуки та зовнішні виклики → залишити як Route Handlers, (c) читання, які фактично використовуються лише в UI → перенести в Server Components.
Перенесення на app/api (1 день на кожні 20–30 ендпоінтів). Файли pages/api/foo.ts стають app/api/foo/route.ts. Змініть сигнатуру: замість (req, res) => … експортуйте іменовані GET/POST. req.body стає await req.json(), res.status(200).json(x) стає NextResponse.json(x).
Перетворення внутрішніх мутацій на Server Actions (2–4 дні для 30–50 форм). Тут найбільша економія. Форма з fetch('/api/tasks', {method:'POST', body: JSON.stringify(data)}) плюс ручний setState плюс toast стає <form action={createTask}> плюс useActionState. Викидайте SWR/React Query hooks, які тепер зайві.
Оновлення кешування (0.5–1 день). Пройдіться по GET Route Handlers, які лишились. У 16 дефолт динамічний, тож для ендпоінтів на кшталт «список публічних категорій» додайте export const revalidate або "use cache".
Аудит безпеки (0.5–1 день). Переконайтесь, що NEXT_SERVER_ACTIONS_ENCRYPTION_KEY заданий в env продакшена (Vercel робить це автоматично, самохост ні). Перевірте, що кожен новий Server Action робить auth-перевірку. Додайте rate-limit на публічні Route Handlers.
Разом: 4–8 днів на розробника для типового SaaS із ~50 мутаційними ендпоінтами. Найдовше зазвичай не сам код, а retest UI-флоу і виправлення тонких кейсів із revalidateTag.
Ні. Server Actions не мають публічного URL, не підтримують GET та інші HTTP-методи, окрім POST, і не можуть приймати зовнішні виклики. Для вебхуків, мобільних клієнтів, OAuth-callbacks і будь-якого публічного API потрібні Route Handlers.
Чи можна викликати Server Action із клієнтського компонента?
Так. Клієнтський компонент імпортує серверну функцію з файлу з директивою 'use server' угорі і викликає її як звичайну функцію. Next.js під капотом робить POST-запит, серіалізує аргументи та повертає відповідь. Тип аргументів і повернення зберігається наскрізно.
Чи потрібен CSRF-токен для Server Actions?
Ні, він генерується та перевіряється фреймворком автоматично. Ви не бачите його у власному коді. Крім того, Next.js перевіряє заголовок Origin і шифрує ідентифікатор дії ключем NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, тож класичні CSRF-сценарії заблоковані з коробки.
Чи можна кешувати відповідь Server Action?
Ні, Server Actions завжди йдуть через POST і не кешуються ні браузером, ні CDN. Це навмисно, щоб уникнути помилкового кешування мутацій. Якщо вам потрібне кешоване читання, використовуйте Server Component з директивою "use cache" або GET Route Handler з export const revalidate.
Чи можна використати Server Action для стрімінгу відповіді (SSE, LLM)?
Технічно можна повертати ReadableStream через RSC-стрім, але це погана ідея для довгих стрімів LLM: втрачаєте контроль над backpressure, cancellation і не можете легко перевикористати ендпоінт із мобільного клієнта. Для чатів LLM і SSE ставте окремий Route Handler з export const runtime = 'edge'.
Що обрати для мобільного застосунку, який використовує той самий бекенд?
Route Handlers. Мобільний застосунок не може викликати Server Action, у нього немає React Server Components runtime. Створіть окремий префікс на кшталт /api/mobile/v1/* з версіонуванням, JSON-контрактом та Bearer-авторизацією. Server Actions залишаться для web-UI.
Як створити безпечні форми в Next.js App Router: Zod-валідація, useActionState, автентифікація, авторизація та next-safe-action — з прикладами коду для продакшну.