Дії сервера чи обробники маршрутів? Посібник з прийняття рішень для Next.js 16
Дізнайтеся, коли мутація у Next.js 16 слід розміщувати у Server Action, а коли їй потрібен Route Handler, з виправленими прикладами для форм, помилок, оптимізму та вебхуків.
Server Actions з’явилися у Next.js 13.4 як спрощений спосіб надсилання форм та тепер є основною складовою App Router, проте кожна нова функція ставить одне й те саме питання: дія чи API-шлях? Ось модель мислення для вибору, приклади обох підходів у коді та способи усунення хибних уявлень, які призводять до справжніх помилок.
Два різних формати
API-шляхи, які в App Router називаються Route Handlers (app/api/.../route.ts), — це звичайні HTTP-кінцеві точки. Вони мають стабільні URL, які може викликати будь-який клієнт, повний контроль над кодами стану та заголовками кешування, а також не мають зв’язку з вашими компонентами.
Server Actions — це функції з маркером 'use server', які викликаються з вашого коду React. Next.js генерує кінцеву точку, серіалізує аргументи та результати та під’єднує їх до форм, переходів та оптимістичного інтерфейсу, при цьому типи передаються на місце виклику.
На відміну від поширеного твердження, Server Actions не є приватними: кожен з них — це точка входу типу POST, позначена ідентифікатором дії, і будь-хто, хто має цей ідентифікатор, може її викликати з довільними аргументами.
Основне правило: зовнішні викликачі отримують Route Handler; зміни, спричинені вашим власним інтерфейсом, зазвичай підходять для Server Action.
Server Actions на практиці
Зміна через форму
Файл починається з директиви, яка позначає кожен експорт як серверну функцію:
// app/actions/order.ts
'use server'
Ця дія автентифікує користувача, перевіряє поля, записує замовлення, знову перевіряє /orders та перенаправляє. Функція auth() читає куки запиту, тому клієнт не передає жодного токена.
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'export async function createOrder(formData: FormData) {
// Auth is automatic — no need to pass session
const session = await auth()
if (!session?.user) throw new Error('Unauthorized') const item = formData.get('item') as string
const quantity = Number(formData.get('quantity')) // Validate
if (!item || quantity < 1) {
return { error: 'Invalid order data' }
} // Database write
const order = await db.order.create({
data: { item, quantity, userId: session.user.id },
}) // Invalidate cached data
revalidatePath('/orders') // Redirect after success
redirect(`/orders/${order.id}`)
}
Зауважте, що функція redirect працює шляхом кидання винятку, тому ніколи не викликайте її всередині блоку try, який приховує помилки. Сторінка імпортує цю дію:
// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'
і передає його безпосередньо до властивості action форми:
export default function ShopPage() {
return (
<form action={createOrder}>
<input name="item" type="text" placeholder="Item name" />
<input name="quantity" type="number" defaultValue={1} />
<button type="submit">Order</button>
</form>
)
}
Немає стану клієнта, ефекту чи fetch, і форма надсилає дані ще до завантаження JavaScript.
Повернення помилок за допомогою useActionState
useActionState (React 19) зберігає останнє значення, повернуте дією, та флаг очікування:
'use client'
import { useActionState } from 'react'
import { createOrder } from '@/app/actions/order'
export function OrderForm() {
const [state, action, isPending] = useActionState(createOrder, null) return (
<form action={action}>
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<input name="item" />
<input name="quantity" type="number" />
<button type="submit" disabled={isPending}>
{isPending ? 'Ordering...' : 'Place Order'}
</button>
</form>
)
}
Одна проблема: коли дія обгорнута useActionState, вона отримує попередній стан як перший аргумент, а FormData — другий. Функція createOrder, наведена вище, приймає лише formData, тому для цієї форми її потрібно змінити на createOrder(prevState, formData).
Оптимістичний зворотний зв’язок за допомогою useOptimistic
useOptimistic відображає очікуваний результат до тих пір, поки дія не завершиться:
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/post'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimistic] = useOptimistic(initialLikes) async function handleLike() {
setOptimistic(prev => prev + 1) // UI updates instantly
await toggleLike(postId) // Server call happens in background
} return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
)
}
Оптимістичний сеттер має виконуватися всередині переходу або дії. Якщо використовується звичайний onClick, необхідно обгорнути його тіло функцією startTransition; інакше React видасть попередження, і оптимістичний стан не буде працювати як очікується. Проблеми, пов’язані з відкатом, розглядаються у п’яти режимах збою useOptimistic.
Коли Route Handler — правильний інструмент
Цей версіонований кінцевий пункт демонструє його переваги: параметри запиту, кешування через CDN та автентифікація за допомогою API-ключа.
// app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category') const products = await db.product.findMany({
where: category ? { category } : undefined,
}) return NextResponse.json({ products }, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
}
})
}export async function POST(request: NextRequest) {
const apiKey = request.headers.get('x-api-key')
if (apiKey !== process.env.API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} const body = await request.json()
const product = await db.product.create({ data: body })
return NextResponse.json({ product }, { status: 201 })
}
Виберіть Route Handler у таких випадках:
- мобільні додатки використовують цей кінцевий пункт;
- сторонні сервіси надсилають вебхуки;
- відповіді потребують власних заголовків кешування;
- це публічний, версіонований API або продукт на основі API.
У продакшені необхідно перевіряти вміст POST-запиту, а не передавати його до бази даних без перевірки.
Як вони порівнюються
- Кількість обмінів даними: у обох випадках відбувається один запит від браузера до вашого сервера; жоден з них не є запитом між серверами.
- Клієнтський пакет даних: логіка залишається на сервері у будь-якому випадку.
- Аутентифікація: функції автоматично читають інформацію сесії з куки; обробники, які обслуговують інших клієнтів, перевіряють токени чи ключі для кожного запиту.
- Типи даних: спільні для всіх функцій; обробники потребують клієнта з визначеними типами даних чи схемою.
- Кешування: функції анулюють кеш за допомогою
revalidatePathчи тегів; обробники встановлюють значенняCache-Control. - Оптимістичний інтерфейс: вбудований у хуки React для функцій; для обробників потрібне ручне використання.
На момент написання цього тексту Next.js також відправляє Server Actions з клієнта по одному, тож вони підходять для змін даних, а не для паралельного читання інформації; перевірте актуальну документацію.
Чек-лист для прийняття рішення
Почніть з питання, яке вирішує більшість випадків:
Does an external system call this?
YES → API Route
Потім розгляньте решту:
Is this triggered by a form submit or user action within your UI?
YES → Server ActionDo you need explicit HTTP caching headers?
YES → API RouteDo you want automatic auth context without passing tokens?
YES → Server ActionDo you need to call this from a mobile app?
YES → API RouteEverything else?
→ Server Action (less boilerplate)
Два помилкові підходи, яких слід уникати
Направлення webhook-ів на Server Action
Stripe надсилає дані на зареєстровану URL та не може адресуватися за ідентифікатором дії.
// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }
Webhook-и мають знаходитися у обробнику маршруту, де можна прочитати необроблене тіло повідомлення та перевірити підпис.
// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }
Довіра до вхідних даних дії
Оскільки будь-яку дію можна викликати безпосередньо, дія видалення без перевірок дозволяє будь-кому видаляти все, що завгодно:
// ❌ Wrong — Server Actions are not inherently trusted
'use server'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } }) // Anyone can call this!
}
Щоб виправити це, спочатку перевіряється сесія та право власності перед записом, а потім здійснюється повторна перевірка:
// ✅ Correct — always validate auth + ownership
'use server'
export async function deletePost(postId: string) {
const session = await auth()
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.userId !== session?.user?.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
Також явно відхиляйте відсутні сесії. Детальніше про це розглядається у нашій статті autorизація всередині кожної Server Action.
Ключові моменти
- Кожна Server Action є доступним кінцевим пунктом; автентифікуйте, авторизуйте та перевіряйте дані всередині неї.
- За допомогою
useActionStateдія спочатку отримує попередній стан. - Виконуйте оновлення за допомогою
useOptimisticпід час переходу.
Пов’язана література
- useOptimistic Rollback: П’ять способів збою в Server Action Next.js — Дізнайтеся, чому useOptimistic безпомічно скасовує зміни в інтерфейсі, не пояснюючи користувачам причини збоїв, через п’ять перевірених способів збою Server Action та робоче рішення.