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.
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ą
revalidatePathlub 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
useActionStateakcja najpierw otrzymuje poprzedni stan. - Warto wykonywać aktualizacje przy użyciu
useOptimisticw trakcie przejść między stanami.
Powiązane materiały
- useOptimistic Rollback: Pięć trybów awarii w akcjach serwerowych Next.js — Dowiedz się, dlaczego funkcja useOptimistic cicho przywraca interfejs bez informowania użytkowników o błędach, poprzez pięć sprawdzonych trybów awarii akcji serwerowych oraz praktyczne rozwiązanie.