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

Дії сервера чи обробники маршрутів? Посібник з прийняття рішень для Next.js 16

Дізнайтеся, коли мутація у Next.js 16 слід розміщувати у Server Action, а коли їй потрібен Route Handler, з виправленими прикладами для форм, помилок, оптимізму та вебхуків.

1558 слів

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 під час переходу.

Пов’язана література

  • Чому 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.