Przejdź do treści

Co wybrać: Route Handlers czy Server Actions?

Route Handler czy Server Action w Next.js? Porównuję kontrakt HTTP, formularze, bezpieczeństwo, cache, błędy i integracje, aby ułatwić decyzję architektoniczną.

Maciej Sala

Founder StriveLab

8 min czytaniaAktualizacja

Route Handlers czy Server Actions: najkrótsza odpowiedź

Wybierz Route Handler w sytuacji, kiedy operacja wymaga dedykowanego punktu końcowego (stałego adresu URL, konkretnej metody HTTP, własnych nagłówków i statusów czy specyficznego formatu odpowiedzi) i ma być konsumowana przez zewnętrzne systemy lub klientów spoza interfejsu Next.js. Kiedy jednak mutacja służy do obsługi formularzy bądź bezpośrednich interakcji w komponencie Reacta i jej celem jest natychmiastowe odświeżenie stanu UI, właściwym wyborem będzie Server Action.

PytanieWybór
Czy klientem jest aplikacja mobilna, webhook lub inny serwis?Route Handler
Czy potrzebujesz GET, pliku, SSE, CORS albo własnych nagłówków?Route Handler
Czy operacja wynika bezpośrednio z formularza lub przycisku Reacta?Server Action
Czy potrzebujesz useActionState, useOptimistic albo progresywnego ulepszania?Server Action
Czy Server Component tylko pobiera dane?Bezpośrednie wywołanie serwisu lub bazy

Warto pamiętać, że w dokumentacji Reacta pojęciem nadrzędnym jest Server Function. Z kolei Server Action to po prostu taka funkcja serwerowa, którą osadzono bezpośrednio w kontekście mutacji, na przykład przekazując ją do atrybutu action lub formAction albo wywołując wewnątrz startTransition.

Wspólna logika biznesowa zamiast dwóch implementacji

Zarówno Route Handler, jak i Server Action pełnią wyłącznie rolę adapterów wejściowych. Żaden z tych mechanizmów nie powinien samodzielnie implementować logiki biznesowej, takiej jak kalkulacja cen, weryfikacja uprawnień czy bezpośredni zapis produktu do bazy. Cała ta logika powinna spoczywać we wspólnym serwisie domenowym, który operuje na danych całkowicie odseparowanych od warstwy transportowej, bez zależności od obiektów Request, Response czy struktury FormData.

Code
// lib/services/product-service.ts
import 'server-only'
import { db } from '@/lib/db'
import { z } from 'zod'
 
export const createProductSchema = z.object({
  name: z.string().trim().min(2).max(100),
  priceInCents: z.number().int().positive(),
  category: z.enum(['electronics', 'clothing']),
})
 
export type CreateProductInput = z.infer<typeof createProductSchema>
export type Actor = { userId: string; canCreateProducts: boolean }
 
export class ForbiddenError extends Error {}
 
export async function createProduct(actor: Actor, input: CreateProductInput) {
  if (!actor.canCreateProducts) throw new ForbiddenError('Forbidden')
 
  return db.product.create({
    data: { ...input, ownerId: actor.userId },
  })
}

Choć wstępna walidacja danych wejściowych powinna odbywać się na samej granicy warstwy transportowej, serwis domenowy nadal oczekuje ścisłego, typowanego modelu i samodzielnie egzekwuje reguły niezależne od użytego adaptera. Wprowadzenie dedykowanej klasy wyjątku, np. ForbiddenError, pozwala jednoznacznie zmapować odmowę dostępu na status HTTP 403 w Route Handlerze lub odpowiedni komunikat w Server Action, bez ryzyka pomylenia błędu biznesowego z awarią infrastruktury.

Route Handlers: jawny kontrakt HTTP

Route Handler definiuje się w pliku route.ts wewnątrz katalogu app/, opierając jego działanie na natywnych interfejsach Web API: Request oraz Response. Next.js automatycznie rozpoznaje i obsługuje w tym pliku eksportowane metody HTTP: GET, POST, PUT, PATCH, DELETE, HEAD oraz OPTIONS.

Code
// app/api/products/route.ts -> POST /api/products
import { requireApiActor } from '@/lib/api-auth'
import {
  createProduct,
  createProductSchema,
  ForbiddenError,
} from '@/lib/services/product-service'
import { z } from 'zod'
 
export async function POST(request: Request) {
  const actor = await requireApiActor(request)
  if (!actor) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  let body: unknown
  try {
    body = await request.json()
  } catch {
    return Response.json({ error: 'Invalid JSON' }, { status: 400 })
  }
 
  const parsed = createProductSchema.safeParse(body)
  if (!parsed.success) {
    return Response.json(
      {
        error: 'Invalid input',
        fields: z.flattenError(parsed.error).fieldErrors,
      },
      { status: 422 },
    )
  }
 
  try {
    const product = await createProduct(actor, parsed.data)
    // Tylko pola potrzebne klientowi, bez ownerId
    const { id, name, priceInCents, category } = product
    return Response.json({ id, name, priceInCents, category }, { status: 201 })
  } catch (error) {
    if (error instanceof ForbiddenError) {
      return Response.json({ error: 'Forbidden' }, { status: 403 })
    }
 
    console.error('Product creation failed', error)
    return Response.json({ error: 'Internal server error' }, { status: 500 })
  }
}

Ponieważ powyższy przykład koncentruje się na samej granicy warstwy HTTP, funkcja requireApiActor() pełni rolę pomocniczą i odpowiada za weryfikację tokenu lub aktywnej sesji. Przewidywalny błąd domenowy jest w tym miejscu jawnie mapowany na status 403 Forbidden, natomiast nieoczekiwany wyjątek zostaje zarejestrowany w logach i zwrócony jako ogólny błąd 500 Internal Server Error, bez ujawniania jakichkolwiek szczegółów technicznych klientowi.

Kiedy Route Handler jest właściwy?

  • Publiczne lub partnerskie API
  • Aplikacja mobilna albo inny frontend
  • Webhook, OAuth callback i integracja zewnętrzna
  • Download, XML, obraz, RSS lub inny format niż UI Reacta
  • CORS, własne statusy, nagłówki i wersjonowany kontrakt
  • Streaming HTTP, w tym Server-Sent Events

Route Handler może pełnić funkcję wzorca Backend-for-Frontend w sytuacjach, gdy przeglądarka faktycznie potrzebuje tradycyjnego kontraktu HTTP, na przykład przy cyklicznym odpytywaniu endpointu (polling) za pomocą bibliotek takich jak SWR czy TanStack Query. Nie należy go jednak tworzyć wyłącznie po to, aby komponent serwerowy wywoływał fetch() do własnego punktu końcowego /api. Jeśli taki komponent jest prerenderowany na etapie budowania, fetch() zakończy build błędem, bo nie działa wtedy jeszcze żaden serwer HTTP. Z kolei przy renderowaniu na żądanie wprowadzamy w ten sposób całkowicie zbędny skok sieciowy i narzut opóźnienia.

Webhook wymaga czegoś więcej niż weryfikacji podpisu

Punkt końcowy webhooka powinien w pierwszej kolejności odczytać surowe body (np. przez await request.text()), zweryfikować podpis kryptograficzny dostawcy i zarejestrować zdarzenie wraz z jego unikalnym identyfikatorem. Należy jednak pamiętać, że samo sprawdzenie obecności zdarzenia w tabeli processed_events nie gwarantuje semantyki exactly-once. Jeśli awaria systemu nastąpi tuż po wykonaniu efektu ubocznego w domenie, ale przed zapisaniem statusu completed, ponowione żądanie od dostawcy doprowadzi do powstania duplikatu.

Znacznie bezpieczniejszym wzorcem jest natychmiastowy zapis napływającego zdarzenia do transakcyjnego inboxa, szybkie zwrócenie odpowiedzi ze statusem 2xx i oddelegowanie właściwego przetwarzania do asynchronicznej kolejki z idempotentnym handlerem domenowym. Jest to kluczowe z dwóch powodów: dostawcy zewnętrzni mogą wysyłać zdarzenia wielokrotnie oraz w nieregularnej kolejności, a długotrwałe wykonywanie logiki biznesowej bezpośrednio wewnątrz Route Handlera zwiększa ryzyko przekroczenia limitu czasu (timeout) na platformach serverless.

Server Actions: mutacje sprzężone z Reactem

Server Action jest asynchroniczną funkcją serwerową wywoływaną żądaniem HTTP POST. Nie należy jej jednak projektować jako publicznego, stabilnego punktu końcowego dla zewnętrznych klientów. React przesyła argumenty do tej funkcji za pomocą własnego, dedykowanego protokołu, a Next.js potrafi w ramach pojedynczej odpowiedzi serwera zwrócić zarówno wynik wykonanej mutacji, jak i zaktualizowane drzewo komponentów UI.

Code
// app/products/actions.ts
'use server'
 
import { requireActor } from '@/lib/auth'
import { createProduct, ForbiddenError } from '@/lib/services/product-service'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
 
const productFormSchema = z.object({
  name: z.string().trim().min(2).max(100),
  price: z.coerce.number().positive().multipleOf(0.01),
  category: z.enum(['electronics', 'clothing']),
})
 
export type ProductFormState = {
  message: string
  status: 'idle' | 'error' | 'success'
}
 
export async function createProductAction(
  _previousState: ProductFormState,
  formData: FormData,
): Promise<ProductFormState> {
  const actor = await requireActor()
  if (!actor) return { status: 'error', message: 'Zaloguj się' }
 
  const parsed = productFormSchema.safeParse({
    name: formData.get('name'),
    price: formData.get('price'),
    category: formData.get('category'),
  })
  if (!parsed.success) {
    return { status: 'error', message: 'Sprawdź dane formularza' }
  }
 
  const { price, ...product } = parsed.data
  try {
    await createProduct(actor, {
      ...product,
      priceInCents: Math.round(price * 100),
    })
  } catch (error) {
    if (error instanceof ForbiddenError) {
      return { status: 'error', message: 'Brak uprawnień' }
    }
    throw error
  }
  revalidatePath('/products')
  return { status: 'success', message: 'Produkt został dodany' }
}

Użycie liczby jednostek minimalnych w warstwie domenowej ogranicza problemy z arytmetyką zmiennoprzecinkową. Formularz może przyjmować wartość dziesiętną, ale przed zapisem przelicza ją na całkowite priceInCents.

Code
// app/products/new/product-form.tsx
'use client'
 
import { useActionState } from 'react'
import { createProductAction, type ProductFormState } from '../actions'
 
const initialState: ProductFormState = { status: 'idle', message: '' }
 
export function ProductForm() {
  const [state, formAction, isPending] = useActionState(
    createProductAction,
    initialState,
  )
 
  return (
    <form action={formAction}>
      <label htmlFor="name">Nazwa</label>
      <input id="name" name="name" required />
 
      <label htmlFor="price">Cena</label>
      <input
        id="price"
        name="price"
        type="number"
        min="0.01"
        step="0.01"
        required
      />
 
      <label htmlFor="category">Kategoria</label>
      <select id="category" name="category">
        <option value="electronics">Elektronika</option>
        <option value="clothing">Odzież</option>
      </select>
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Dodawanie...' : 'Dodaj produkt'}
      </button>
      {state.message && <p aria-live="polite">{state.message}</p>}
    </form>
  )
}

Formularz osadzony w komponencie serwerowym zostanie wysłany nawet przy wyłączonym JavaScripcie w przeglądarce. Nie pokaże jednak stanu oczekiwania, a po wysłaniu przeglądarka przeładuje stronę. Z kolei formularz w komponencie klienckim wysłany przed zakończeniem hydratacji zostaje zakolejkowany, a React w pierwszej kolejności hydratuje właśnie ten formularz. Natomiast wywołanie Server Action z onClick czy useEffect wymaga już aktywnego JavaScriptu i powinno być zawsze opakowane w startTransition. Jeśli zależy nam na natychmiastowej reakcji interfejsu, możemy zastosować hook useOptimistic, pamiętając jednak, że optymistyczna zmiana widoku jedynie maskuje opóźnienie sieciowe i w żaden sposób nie zastępuje walidacji ani potwierdzenia zapisu przez serwer.

Kiedy Server Action jest właściwa?

  • Formularz tworzący, edytujący lub usuwający dane
  • Mutacja wywołana przez przycisk lub interakcję Reacta
  • Stan błędu i oczekiwania obsługiwany przez useActionState

  • Optymistyczna aktualizacja przez useOptimistic
  • Odświeżenie UI i cache po mutacji

Porównanie Route Handlers i Server Actions

CechaRoute HandlersServer Actions
KontraktURL, metoda, status, nagłówki i bodyWewnętrzny protokół React/Next.js
MetodyGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSPOST
Zewnętrzni konsumenciTakNie jako wspierany kontrakt
Formularze ReactRęczna obsługaNatywne action i formAction
Oczekiwane błędyStatus HTTP i bodyZwracany stan, np. useActionState
RewalidacjarevalidatePath, revalidateTagDodatkowo updateTag i odświeżenie UI
SSE i kontrola ResponseTakNie
Zaktualizowane UI RSC w odpowiedziNie dotyczyTak, po revalidatePath, updateTag, refresh lub redirect
Progresywne ulepszanieZależy od klientaTak dla formularzy
Type safety klient-serwerWymaga schematu lub klienta APIWynika z importowanej funkcji, ale runtime nadal wymaga walidacji

Bezpieczeństwo: oba mechanizmy są publicznym wejściem

Dyrektywa 'use server' nie oznacza prywatności. Argumenty Server Action są kontrolowane przez klienta, a wyeksportowana akcja jest osiągalna przez żądanie POST. Next.js używa niedeterministycznych identyfikatorów, usuwa nieużywane akcje i porównuje Origin z Host albo X-Forwarded-Host, ale te zabezpieczenia nie zastępują autoryzacji.

Pamiętaj, żeby sprawdzać nie tylko to, czy użytkownik jest zalogowany, ale czy może zmienić konkretny rekord. Dla operacji na zasobie preferuj atomowy warunek, na przykład deleteMany({ where: { id, ownerId: actor.userId } }), zamiast osobnego odczytu i późniejszego usunięcia podatnego na wyścig. Route Handlery nie dostają automatycznie identycznego modelu ochrony CSRF. Mutacje oparte na cookie sesyjnym powinny weryfikować origin lub token CSRF. Publiczne API wymaga również świadomego CORS, rate limitingu, limitów body i jednoznacznego uwierzytelnienia. Nie przekazuj też do klienta surowych wyjątków ani nadmiarowych pól z bazy.

Cache, rewalidacja i limity

Route Handlery nie są domyślnie cache'owane. Bez Cache Components możesz jawnie ustawić dynamic = 'force-static' dla GET. Przy włączonych Cache Components deterministyczny handler może zostać prerenderowany. Dane z bazy lub API cache'uj w wydzielonej funkcji z dyrektywą 'use cache', bo dyrektywy nie można umieścić bezpośrednio w ciele Route Handlera. Do takiej funkcji nie trafiają dane z żądania, np. cookies() czy headers(). revalidatePath i revalidateTag działają w obu mechanizmach. Wywołanie revalidatePath z Route Handlera oznacza ścieżkę do odświeżenia przy kolejnej wizycie. W Server Action może dodatkowo od razu zaktualizować oglądany interfejs. updateTag, zapewniający scenariusz read-your-own-writes, jest dostępny tylko w Server Actions.

Pamiętaj, że argumenty oraz wynik Server Function muszą należeć do typów serializowalnych przez React. Domyślny limit body Server Actions wynosi 1 MB i można go zmienić opcją experimental.serverActions.bodySizeLimit w next.config, ale duże pliki lepiej wysyłać bezpośrednio do storage. Route Handler daje kontrolę nad Request, Response, nagłówkami i streamingiem, jednak nadal podlega limitom platformy.

Błędy i obserwowalność

W architekturze Route Handlera każdy przewidywalny błąd należy jawnie zmapować na odpowiedni kod statusu HTTP oraz spójny format odpowiedzi.

  • Nieprawidłowa składnia JSON-a — 400 Bad Request.
  • Błąd walidacji schematu — 422 Unprocessable Content.

  • Brak aktywnej sesji — 401 Unauthorized.
  • Brak wystarczających uprawnień — 403 Forbidden.
  • Nieobsłużony wyjątek — ogólny błąd 500 Internal Server Error, z unikalnym identyfikatorem zdarzenia (correlation ID) dołączonym do odpowiedzi lub logów, co ułatwia późniejsze debugowanie.

W przypadku Server Actions przewidywalne problemy, takie jak błędy walidacji formularza, należy zwracać bezpośrednio jako obiekty stanu obsługiwane przez hook useActionState. Rzucanie wyjątków warto zarezerwować wyłącznie dla krytycznych, nieobsłużonych awarii, które powinny zostać przechwycone przez najbliższy komponent error.tsx. Po pomyślnym wykonaniu operacji funkcja może zwrócić zaktualizowany stan interfejsu lub przekierować użytkownika za pomocą redirect(). Ten podział odpowiedzialności jest ważniejszy niż sama różnica składni między fetch() a bezpośrednim wywołaniem funkcji.

Ostateczna decyzja

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy Server Actions mogą zastąpić endpoint GET?

Nie powinny. Server Actions używają POST i są projektowane do mutacji. Klient wykonuje je obecnie pojedynczo, więc nie zastępują równoległego pobierania danych. Server Component powinien czytać dane bezpośrednio ze źródła, a dla przeglądarki lub zewnętrznego klienta utwórz Route Handler.

Czy zewnętrzna aplikacja może korzystać z Server Actions?

Nie traktuj Server Actions jako publicznego API. Ich identyfikatory i protokół transportowy nie są stabilnym kontraktem dla aplikacji mobilnej ani innej usługi. Dla zewnętrznych konsumentów utwórz Route Handler.

Czy Server Actions działają bez JavaScriptu?

Formularz renderowany przez Server Component może wysłać Server Action przed załadowaniem JavaScriptu lub przy wyłączonym JavaScripcie. Formularz w Client Component przed hydratacją kolejkuje wysłanie, a wywołania z onClick lub useEffect wymagają JavaScriptu.

Czy dyrektywa use server zabezpiecza Server Action?

Nie. Argumenty Server Action są kontrolowane przez klienta, dlatego każda akcja musi walidować dane oraz sprawdzać uwierzytelnienie i uprawnienia do konkretnego zasobu.

Jak współdzielić logikę Route Handlera i Server Action?

Umieść reguły biznesowe w serwisie domenowym, a Route Handler i Server Action potraktuj jako adaptery transportowe. Każdy adapter osobno parsuje JSON lub FormData, uwierzytelnia wywołanie i mapuje wynik na HTTP albo stan formularza.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną
LH.pl – Hosting Mango

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Next.js jako backend: architektura aplikacji full-stack

Next.js może obsługiwać interfejs, logikę biznesową i dostęp do bazy w jednym projekcie. Ten przewodnik pokazuje, jak połączyć komponenty serwerowe, Server Actions i Route Handlers z autoryzacją, cache i zadaniami w tle. Na przykładzie panelu zamówień przejdziemy od odczytu danych do zapisu i utrzymania na produkcji, wskazując momenty, w których warto wydzielić osobną usługę.

Maciej Sala

Maciej Sala

Founder StriveLab

React Server Components i Server Actions: SEO i wydajność

App Router pozwala przesunąć pobieranie danych i renderowanie treści na serwer, zostawiając w przeglądarce małe obszary interakcji. Taki podział może ograniczyć JavaScript i ułatwić dostarczenie treści w HTML, ale nie gwarantuje dobrych Core Web Vitals ani indeksacji. Wynik zależy też od czasu odpowiedzi serwera, cache, obrazów, fontów, metadanych i bezpieczeństwa mutacji.

Maciej Sala

Maciej Sala

Founder StriveLab

Repository i Service Layer w Next.js dla Server Actions

W Next.js traktuj Server Actions i Route Handlers jako cienkie adaptery wejścia: pobierają sesję, walidują payload i mapują odpowiedź. Reguły biznesowe trzymaj w Service Layer, zapytania do bazy w Repository, a transakcje spinaj przez Unit of Work. Taki podział nie jest potrzebny w każdym CRUD-zie, ale zaczyna się zwracać, gdy jedna operacja dotyka kilku tabel, kilku wejść albo kilku reguł.

Maciej Sala

Maciej Sala

Founder StriveLab

LH.pl – Cloud Server 1C4G