Действия сервера или обработчики маршрутов? Руководство по принятию решений для Next.js 16
Узнайте, когда мутация в Next.js 16 следует размещать в Server Action, а когда ей требуется обработчик маршрута, с исправленными примерами для форм, ошибок, оптимизма и вебхуков.
Функции сервера появились в Next.js 13.4 как упрощение для отправки форм и теперь являются основным элементом App Router, однако каждая новая функция вызывает один и тот же вопрос: использовать действие сервера или API-маршрут? Здесь приведена модель принятия решения, примеры обоих подходов в коде, а также способы устранения заблуждений, приводящих к реальным ошибкам.
Два разных подхода
API-маршруты, в App Router называемые обработчиками маршрутов (app/api/.../route.ts), представляют собой обычные HTTP-конечные точки. У них есть стабильные URL, к которым может обращаться любой клиент, полный контроль над кодами состояния и заголовками кэширования, а также отсутствие связи с компонентами.
Функции сервера — это функции с модификатором 'use server', вызываемые из кода React. Next.js генерирует соответствующую конечную точку, сериализует аргументы и результаты, а затем интегрирует их в формы, переходы и оптимистичный интерфейс, при этом типы данных передаются на место вызова.
Вопреки распространённому мнению, Server Actions не являются приватными: каждый из них представляет собой точку входа типа POST, идентифицируемую ID действия, и любой, у кого есть это ID, может вызвать его с произвольными аргументами.
Ориентир: внешние вызывающие получают 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.
Когда подходит обработчик маршрута
Этот версионированный конечный пункт демонстрирует его преимущества: параметры запроса, кэширование через 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 })
}
Выбирайте обработчик маршрута, когда:
- конечный пункт используется мобильными приложениями;
- вебхуки доставляются сторонними сервисами;
- ответам требуются собственные заголовки кэширования;
- речь идет о публичном, версионированном 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 и не может указывать ID действия.
// ❌ 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')
}
Также явно отклоняйте запросы с отсутствующими сессиями. Подробнее об этом рассказано в нашей статье о необходимости авторизации внутри каждой функции Server Action.
Основные выводы
- Каждая функция Server Action представляет собой доступную точку входа; необходимо выполнять аутентификацию, авторизацию и проверку данных внутри нее.
- С помощью
useActionStateфункция сначала получает предыдущее состояние. - Обновления с использованием
useOptimisticвыполняются во время перехода состояния.
Связанные материалы
- useOptimistic Rollback: Пять способов сбоя в Server Action Next.js — Узнайте, почему функция useOptimistic бесшумно восстанавливает интерфейс без объяснения причин сбоев пользователю, на примере пяти проверенных способов сбоя в Server Action и рабочего решения.