Главная / Статьи / Действия сервера или обработчики маршрутов? Руководство по принятию решений для Next.js 16

Действия сервера или обработчики маршрутов? Руководство по принятию решений для Next.js 16

Узнайте, когда мутация в Next.js 16 следует размещать в Server Action, а когда ей требуется обработчик маршрута, с исправленными примерами для форм, ошибок, оптимизма и вебхуков.

1558 слов

Функции сервера появились в 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 и рабочего решения.
  • Почему Server Actions в Next.js требуют авторизации внутри тела каждой функции — Анализ случая захвата учетной записи показывает, как Server Actions в Next.js без аутентификации позволяют выполнять привилегированные операции, и где необходимо размещать проверку авторизации для предотвращения этого.
  • План обновления до React 19: какие новые API заменяют существующие решения — Практический обзор использования Server Actions, функции useOptimistic и React Compiler, включая важные ограничения и план по первоочередному внедрению изменений в React 19.
  • Где провести границу между серверной и клиентской частью на странице App Router в Next.js — практическая модель для React Server Components: что выполняется где, как страница блога делятся на серверную и клиентскую части, а также правила импорта, которым следует придерживаться.
  • Next.js или обычный React? Подбор фреймворка под ваш проект — метод, основанный на анализе требований, для определения того, нужен ли новому проекту на React Next.js, с учётом SEO, гибридной отрисовки, ограничений хостинга, потребностей в маршрутизации и опыта команды.
  • Единый источник истины для путей маршрутов в приложениях Next.js — Узнайте, как модуль централизованных маршрутов с типизацией заменяет разрозненные строковые пути в Next.js, а также о вспомогательных функциях для динамических сегментов и группировки экспортов маршрутов.
  • Почему WebSockets застревают в маршрутах API Next.js и как это исправляет пользовательский сервер — Узнайте, почему сервер ws внутри маршрута pages/api никогда не завершает процедуру установления соединения, и как самостоятельно обрабатывать событие upgrade с помощью пользовательского сервера Next.js.