Strona główna / Artykuły / Działania serwera czy obsługi tras? Przewodnik decyzyjny dla Next.js 16

Działania serwera czy obsługi tras? Przewodnik decyzyjny dla Next.js 16

Dowiedz się, kiedy mutacja w Next.js 16 powinna znajdować się w Server Action, a kiedy wymaga Route Handlera, z poprawionymi przykładami dotyczącymi formularzy, błędów, podejścia optymistycznego oraz webhooków.

1558 słów

Server Actions pojawiły się w Next.js 13.4 jako skrót do wysyłania formularzy i obecnie stanowią podstawową funkcję App Router, jednak każda nowa funkcjonalność rodzi to samo pytanie: działanie serwera czy ścieżka API? Oto model myślowy pomagający w wyborze, przykłady obu wzorców w kodzie oraz rozwiązania błędnych założeń, które powodują prawdziwe błędy.

Dwa różne kontrakty

Ścieżki API, nazywane w App Router Route Handlers (app/api/.../route.ts), to zwykłe punkty końcowe HTTP. Mają stabilne adresy URL, do których może się odwołać każdy klient, pełną kontrolę nad kodami stanu i nagłówkami cache’owania oraz nie mają żadnego związku z komponentami.

Server Actions to funkcje 'use server' wywoływane z kodu React. Next.js generuje odpowiedni punkt końcowy, serializuje argumenty i wyniki oraz łączy je z formularzami, przejściami i optymistycznym interfejsem użytkownika, przy czym typy są przekazywane do miejsca wywołania.

Wbrew powszechnemu mniemaniu, Server Actions nie są prywatne: każda z nich to punkt końcowy typu POST oznaczony identyfikatorem działania, a ktokolwiek posiada ten identyfikator może ją wywołać z dowolnymi argumentami.

Zasada ogólna: zewnętrzni użytkownicy korzystają z Route Handler; mutacje uruchamiane przez własną interfejs użytkownika zazwyczaj pasują do Server Action.

Server Actions w praktyce

Mutacja oparta na formularzu

Plik zaczyna się od dyrektywy, która oznacza każdą funkcję eksportowaną jako funkcję serwera:

// app/actions/order.ts
'use server'

Działanie to uwierzytelnia użytkownika, weryfikuje pola, zapisuje zamówienie, ponownie sprawdza /orders i przekierowuje użytkownika. Funkcja auth() odczytuje pliki cookie z żądania, więc klient nie musi przekazywać żadnego tokena.

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}`)
}

Należy pamiętać, że funkcja redirect działa poprzez rzucanie błędem, dlatego nigdy nie należy jej wywoływać w bloku try, który pochłania błędy. Strona importuje to działanie:

// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'

i przekazuje je bezpośrednio do właściwości action formularza:

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>
  )
}

Brak stanu klienta, efektów ani funkcji fetch, a formularz jest wysyłany nawet przed załadowaniem JavaScripta.

Zwracanie błędów przy użyciu useActionState

useActionState (React 19) przechowuje ostatnią wartość zwróconą przez akcję oraz flagę wyczekiwania:

'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>
  )
}

Jedna trudność: gdy akcja jest otoczona przez useActionState, otrzymuje poprzedni stan jako pierwszy argument, a FormData jako drugi. Podpis funkcji createOrder pokazany powyżej przyjmuje tylko formData, więc w tym przypadku musi stać się createOrder(prevState, formData).

Optymistyczna informacja zwrotna przy użyciu useOptimistic

useOptimistic pokazuje oczekiwany wynik, dopóki akcja nie zostanie zakończona:

'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>
  )
}

Optymistyczny setter musi być wykonywany w ramach przejścia lub akcji. W przypadku zwykłego onClick należy otoczyć jego treść funkcją startTransition; w przeciwnym razie React wyświetli ostrzeżenie, a stan optymistyczny nie będzie działał zgodnie z oczekiwaniami. Pułapki związane z cofaniem zmian są omówione w artykule pięć trybów awarii funkcji useOptimistic.

Kiedy Route Handler jest odpowiednim rozwiązaniem

Ta wersjonowana ścieżka pokazuje ich zalety: parametry zapytania, cacheowanie w CDN oraz autoryzacja za pomocą klucza 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 })
}

Wybierz Route Handler w następujących sytuacjach:

  • aplikacje mobilne korzystają z tej ścieżki;
  • strona trzecia dostarcza webhooki;
  • odpowiedzi wymagają własnych nagłówków cacheowania;
  • jest to publiczna, wersjonowana API lub produkt API.

W środowisku produkcyjnym należy zweryfikować treść żądania POST, zamiast przekazywać ją do bazy danych bez sprawdzenia.

Jak one się porównują

  • Podróże tam i z powrotem: obie metody wymagają jednego żądania od przeglądarki do serwera; żadna z nich nie polega na komunikacji między serwerami.
  • Pakiet klienta: logika pozostaje na serwerze we wszystkich przypadkach.
  • Autoryzacja: funkcje automatycznie odczytują sesję z plików cookie; obsługi innych klientów sprawdzają tokeny lub klucze przy każdym żądaniu.
  • Typy: są wspólne dla wszystkich funkcji; obsługi wymagają skonfigurowanego klienta lub schematu z określonymi typami.
  • Caching: funkcje unieważniają dane za pomocą revalidatePath lub tagów; obsługi ustawiają wartość Cache-Control.
  • Optymistyczna interfejs użytkownika: wbudowana w hooki React dla funkcji; wymaga ręcznej konfiguracji w przypadku obsług.

W chwili pisania tego tekstu Next.js również wysyła Server Actions z klienta pojedynczo, więc nadają się one raczej do wykonywania mutacji niż do równoległego odczytu danych; sprawdź aktualną dokumentację.

Lista kryteriów decyzyjnych

Zacznij od pytania, które decyduje w większości przypadków:

Does an external system call this?
  YES → API Route

Następnie przejdź do reszty:

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)

Dwa błędy, których należy unikać

Ustawianie webhooków na Server Action

Stripe wysyła dane na zarejestrowany adres URL i nie może celować w identyfikator działania.

// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }

Webhooki powinny znajdować się w Route Handler, gdzie można odczytać surowy treść i zweryfikować podpis.

// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }

Zaufanie do danych wprowadzanych przez działania

Ponieważ każde działanie może być wywołane bezpośrednio, działanie usuwania bez weryfikacji pozwala każdemu usunąć cokolwiek:

// ❌ 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!
}

Rozwiązanie polega na weryfikacji sesji i prawa własności przed zapisem, a następnie ponownej weryfikacji:

// ✅ 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')
}

Należy również wyraźnie odrzucić brakujące sesje. Szczegółowo omawia to nasz artykuł na temat upoważnienia wewnątrz każdej akcji serwerowej.

Główne wnioski

  • Każda akcja serwerowa to dostępny punkt końcowy; należy w niej przeprowadzić autoryzację, upoważnianie oraz walidację.
  • Dzięki useActionState akcja najpierw otrzymuje poprzedni stan.
  • Warto wykonywać aktualizacje przy użyciu useOptimistic w trakcie przejść między stanami.

Powiązane materiały

  • Dlaczego Next.js Server Actions wymagają autoryzacji wewnątrz każdego ciała funkcji — Przykład przejęcia konta pokazuje, jak nieautoryzowane Next.js Server Actions umożliwiają wykonywanie uprzywilejowanych operacji, oraz gdzie musi znajdować się sprawdzanie autoryzacji, aby temu zapobiec.
  • Triaging aktualizacji do React 19: Które nowe API zastępują istniejące rozwiązania — Praktyczny przegląd funkcji use, Server Actions, useOptimistic oraz React Compiler, wraz z istotnymi zastrzeżeniami i planem tego, które zmiany w React 19 należy wprowadzić najpierw.
  • Gdzie narysować linię serwer-klient w stronie app-router w Next.js — Praktyczny model mentalny dla komponentów serwerowych w React: co działa gdzie, jak strona posta na blogu dzieli się na części serwerową i kliencką oraz jakie zasady importu należy stosować.
  • Next.js czy zwykły React? Dobór frameworku do twojego projektu — Metoda oparta na wymaganiach, która pomaga zdecydować, czy nowy projekt w React potrzebuje Next.js, obejmująca kwestie SEO, renderowania hybrydowego, ograniczenia hostingu, potrzeby routingu oraz doświadczenie zespołu.
  • Jedyny źródło prawdy dla dróg w aplikacjach Next.js — Dowiedz się, jak typowany centralny moduł dróg zastępuje rozproszone ścieżki tekstowe w Next.js, dzięki funkcjom pomocniczym do obsługi dynamicznych segmentów i grupowania eksportów dróg.
  • Dlaczego WebSockets zatrzymują się w drogach API Next.js i jak to naprawia customowy serwer — Dowiedz się, dlaczego serwer ws wewnątrz drogi pages/api nigdy nie kończy procedury handshake oraz jak samodzielnie obsłużyć zdarzenie upgrade za pomocą customowego serwera Next.js.