Przejdź do treści

Route Handlers czy Server Actions? Kiedy co wybrać

Wybór między Route Handlers a Server Actions w Next.js to kwestia bezpieczeństwa i architektury. Poznaj różnice i podejmij świadomą decyzję.

Maciej Sala

Founder StriveLab

6 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 13 lipca 2026)

Dwa sposoby na logikę serwerową w App Routerze

Jak pisałem wyżej, nie należy ich traktować naprzemiennie, ponieważ Route Handlers to klasyczne endpointy HTTP, a Server Actions to funkcje serwerowe wołane prosto z komponentów React. Ta różnica pociąga za sobą decyzje o architekturze, bezpieczeństwie, a także wydajności.

W ramach drobnego doprecyzowania: w dokumentacji React spotkasz dziś nazwę Server Functions, a określenie Server Actions jest używane wtedy, gdy te funkcje obsługują akcje i mutacje, najczęściej przez formularze lub interakcje w UI.

Route Handlers w Next.js jako endpointy API

Route Handlers to następca API Routes z Pages Routera. Tworzysz plik route.ts w katalogu app/ i eksportujesz funkcje odpowiadające metodom HTTP. Do walidacji danych wejściowych w przykładzie używam , bo sam TypeScript nie sprawdza treści JSON-a w runtime.

Code
// app/api/products/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { z } from 'zod'
 
const productCategorySchema = z.enum(['electronics', 'clothing'])
 
const createProductJsonSchema = z.object({
  name: z.string().trim().min(2).max(100),
  price: z.number().positive(),
  category: productCategorySchema,
})
 
// GET https://api.example.com/products
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const category = searchParams.get('category')
  const parsedCategory = productCategorySchema.nullable().safeParse(category)
 
  if (!parsedCategory.success) {
    return Response.json({ error: 'Invalid category' }, { status: 400 })
  }
 
  const products = await db.product.findMany({
    where: parsedCategory.data ? { category: parsedCategory.data } : undefined,
    orderBy: { createdAt: 'desc' },
  })
 
  return NextResponse.json(products)
}
 
// POST https://api.example.com/products
export async function POST(request: Request) {
  const session = await auth()
  if (!session?.user?.id) {
    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 = createProductJsonSchema.safeParse(body)
  if (!parsed.success) {
    return Response.json({ error: 'Invalid input' }, { status: 422 })
  }
 
  const product = await db.product.create({
    data: { ...parsed.data, ownerId: session.user.id },
  })
 
  return NextResponse.json(product, { status: 201 })
}

Kiedy wybrać Route Handlers?

  • Zewnętrzni konsumenci. Mobilna aplikacja, inny serwis, webhook, zewnętrzne integracje

  • Publiczne API. Endpointy dostępne bez UI Next.js
  • Webhooks. Stripe, CMS, płatności
  • REST/GraphQL — standardowe interfejsy API
  • Streaming. i długie odpowiedzi

  • Custom auth flow. OAuth callbacks oraz token refresh

  • Kontrakt HTTP dla własnego frontendu — download pliku, , specjalne nagłówki, statusy i odpowiedzi konsumowane przez fetch

Route Handler nie jest przeznaczony wyłącznie do roli publicznego API dla obcych firm, ponieważ może z powodzeniem pełnić funkcję Backend for Frontend (BFF), w sytuacji gdy przeglądarka rzeczywiście potrzebuje kontraktu HTTP. Trzeba pamiętać jednak, by nie tworzyć go tylko po to, by Server Component w tej samej aplikacji wykonywał fetch('/api/products'), bo to zbędny skok HTTP. Zamiast tego, kod serwerowy powinien bezpośrednio wywoływać wspólną funkcję lub operować na bazie danych.

Code
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export async function POST(request: Request) {
  const body = await request.text()
  const headersList = await headers()
  const signature = headersList.get('stripe-signature')
 
  if (!signature) {
    return new Response('Missing signature', { status: 400 })
  }
 
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }
 
  try {
    // Tabela inbox ma unikalny indeks na event.id i stan przetwarzania.
    const shouldProcess = await claimWebhookEvent(event.id)
    if (!shouldProcess)
      return new Response('Already processed', { status: 200 })
 
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object)
        break
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object)
        break
    }
 
    await markWebhookCompleted(event.id)
    return new Response('OK', { status: 200 })
  } catch {
    await markWebhookFailed(event.id)
    // Błąd serwera powinien umożliwić dostawcy ponowienie webhooka.
    return new Response('Webhook processing failed', { status: 500 })
  }
}

Webhooki są dostarczane co najmniej raz i mogą przyjść ponownie lub poza kolejnością. Weryfikacja podpisu jest obowiązkowa, ale sama w sobie nie zapewnia idempotencji. Identyfikator zdarzenia należy zapisywać z unikalnym ograniczeniem oraz stanem processing/completed/failed, a operacje bazodanowe wykonywać transakcyjnie. Ważne jest, aby nie oznaczać zdarzenia jako zakończonego przed osiągnięciem efektu biznesowego, ponieważ nieudana pierwsza próba mogłaby zablokować ponowienie. Ponadto, surowe request.text() musi zostać odczytane przed parsowaniem JSON, gdyż podpis obejmuje dokładne bajty treści.

Server Actions jako funkcje serwerowe w komponentach

Server Actions to funkcje oznaczone dyrektywą "use server", które wywołujesz bezpośrednio z formularzy HTML lub kodu klienta i to bez ręcznego tworzenia endpointów API oraz fetch requestów.

Code
// app/products/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
import { z } from 'zod'
 
const productSchema = z.object({
  name: z.string().min(2).max(100),
  price: z.coerce.number().positive(),
  category: z.enum(['electronics', 'clothing']),
})
 
export async function createProduct(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    return { error: 'Brak uprawnień' }
  }
 
  const parsed = productSchema.safeParse({
    name: formData.get('name'),
    price: formData.get('price'),
    category: formData.get('category'),
  })
 
  if (!parsed.success) {
    return { error: 'Nieprawidłowe dane', details: parsed.error.flatten() }
  }
 
  await db.product.create({
    data: { ...parsed.data, ownerId: session.user.id },
  })
 
  revalidatePath('/products')
  return { success: true }
}
 
export async function createProductWithState(
  _previousState: Awaited<ReturnType<typeof createProduct>> | null,
  formData: FormData,
) {
  return createProduct(formData)
}
Code
// app/products/new/page.tsx
import { createProduct } from '../actions'
 
export default function NewProductPage() {
  return (
    <form action={createProduct}>
      <input name="name" placeholder="Nazwa produktu" required />
      <input name="price" type="number" step="0.01" required />
      <select name="category">
        <option value="electronics">Elektronika</option>
        <option value="clothing">Odzież</option>
      </select>
      <button type="submit">Dodaj produkt</button>
    </form>
  )
}

Kiedy wybrać Server Actions?

  • Formularze. CRUD z natywnych formularzy HTML, progresywne ulepszanie.

  • Mutacje danych. Tworzenie, aktualizacja, usuwanie rekordów.

  • Rewalidacja cache. Jawne revalidatePath, revalidateTag lub updateTag po mutacji.

  • Operacje sprzężone z UI. Bez potrzeby projektowania stabilnego API HTTP.

  • Optimistic updates. Natychmiastowe UI z rollbackiem.

Server Actions z Client Components

Code
'use client'
 
import { useActionState } from 'react'
import { createProductWithState } from '../actions'
 
export default function ProductForm() {
  const [state, formAction, isPending] = useActionState(
    createProductWithState,
    null,
  )
 
  return (
    <form action={formAction}>
      <input name="name" placeholder="Nazwa" required />
      <input name="price" type="number" required />
      <select name="category">
        <option value="electronics">Elektronika</option>
        <option value="clothing">Odzież</option>
      </select>
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Dodawanie...' : 'Dodaj produkt'}
      </button>
 
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Produkt dodany!</p>}
    </form>
  )
}

Route Handlers vs Server Actions: bezpośrednie porównanie

CechaRoute HandlersServer Actions
TypEndpoint HTTP (REST)Funkcja RPC
Wywołaniefetch('https://api.example.com/...')Bezpośrednie z formularza/kodu
Metody HTTPGET, POST, PUT, DELETE, PATCHTylko POST (wewnętrznie)
KontraktStabilny URL, status, nagłówki, bodyWewnętrzny protokół React/Next
FormularzeRęczna obsługaNatywna integracja
RewalidacjaJawna; revalidatePath/revalidateTagJawna; dodatkowo updateTag
StreamingTak (SSE, chunked)Nie
Progresywne ulepszanieZależy od klienta/formularzaTak dla formularzy
Zewnętrzni konsumenciTakNie

Bezpieczeństwo Route Handlers i Server Actions

Route Handlers i samodzielna autoryzacja

Code
// app/api/admin/users/route.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
 
export async function GET() {
  const session = await auth()
 
  if (!session || session.user.role !== 'admin') {
    return new Response('Forbidden', { status: 403 })
  }
 
  const users = await db.user.findMany()
  return Response.json(users)
}

Server Actions: walidacja i autoryzacja w każdej akcji

Server Actions to operacje POST, które są publicznie dostępne i identyfikowane przez generowane identyfikatory. Należy pamiętać, że choć identyfikator może zmieniać się między buildami i nie stanowi publicznego kontraktu API, nie jest to równoznaczne z kontrolą dostępu. Next.js wzmacnia ochronę przed , usuwając nieużywane akcje, ograniczając metodę do POST oraz porównując Origin z Host lub X-Forwarded-Host. Mimo tych zabezpieczeń, każdą wyeksportowaną akcję należy traktować jako potencjalne wejście do systemu, wymagające odpowiedniego zabezpieczenia.

Code
// actions.ts
'use server'
 
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { z } from 'zod'
 
export async function deleteProduct(productId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')
 
  const id = z.string().uuid().parse(productId)
 
  // Sprawdź uprawnienie do konkretnego zasobu, nie tylko zalogowanie.
  const result = await db.product.deleteMany({
    where: { id, ownerId: session.user.id },
  })
 
  if (result.count === 0) throw new Error('Not found')
  revalidatePath('/products')
}

Samo zastosowanie if (session) nie wystarcza, ponieważ poprzednia wersja przykładu pozwalałaby każdemu zalogowanemu użytkownikowi usunąć produkt o znanym UUID (klasyczny IDOR). Uprawnienie sprawdzaj dla konkretnego rekordu, najlepiej atomowo w warunku zapytania, aby uniknąć wyścigu między osobnym odczytem i zapisem.

Route Handlery nie dostają automatycznie takiego samego modelu ochrony CSRF. Jeśli mutują dane na podstawie cookie sesyjnego, weryfikuj origin lub token CSRF i ustaw poprawne atrybuty cookie. Dla API z tokenem w nagłówku zaprojektuj CORS, rate limiting, limity body i jednoznaczne odpowiedzi błędów. W obu mechanizmach waliduj input i autoryzuj operację przy warstwie danych.

Cache, limity i format danych — różnice, które zmieniają decyzję

Route Handlery nie są domyślnie cache'owane. GET możesz jawnie przełączyć na statyczny przez dynamic = 'force-static'. Przy włączonych Cache Components obowiązuje nowszy model z use cache w wywoływanej funkcji. Metody mutujące pozostają dynamiczne. Po mutacji zarówno Route Handler, jak i Server Action mogą wywołać revalidatePath lub revalidateTag. updateTag, zapewniający scenariusz read-your-own-writes, jest dostępny tylko w Server Actions. Rewalidacja nie dzieje się „automatycznie” — integracja Actions pozwala zwrócić świeże UI w tym samym roundtripie, ale to Ty wskazujesz, co unieważnić lub odświeżyć.

Argumenty i wynik Server Function muszą być serializowalne przez React. Nie jest to dobre miejsce na dowolny protokół binarny, kontrolę statusów HTTP czy stabilny format dla innych klientów. Domyślny limit body Server Actions wynosi 1 MB i można go zmienić przez serverActions.bodySizeLimit; zwiększenie limitu nie zastępuje ograniczenia typu oraz rozmiaru pliku ani bezpośredniego uploadu do storage. Route Handler daje pełną kontrolę nad Request i Response, streamingiem, nagłówkami oraz formatem odpowiedzi, ale limity platformy hostingowej nadal obowiązują.

Wzorce architektoniczne dla Route Handlers i Server Actions

Wzorzec 1: Server Actions dla CRUD, Route Handlers dla integracji

Code
app/
├── api/
│   ├── webhooks/stripe/route.ts    ← Route Handler (webhook)
│   ├── auth/callback/route.ts      ← Route Handler (OAuth)
│   └── external/products/route.ts  ← Route Handler (API dla mobile)
├── products/
│   ├── actions.ts                  ← Server Actions (formularze UI)
│   ├── page.tsx
│   └── [id]/
│       └── page.tsx

Wzorzec 2: shared logic layer dla wspólnej logiki

Code
// lib/services/product-service.ts
import 'server-only'
 
export async function createProduct(actor: Actor, input: unknown) {
  if (!actor.permissions.includes('products:create')) {
    throw new ForbiddenError()
  }
 
  const data = productSchema.parse(input)
  const product = await db.product.create({
    data: { ...data, ownerId: actor.userId },
  })
 
  return toProductDto(product)
}
Code
// Server Action korzysta z serwisu
'use server'
import { createProduct } from '@/lib/services/product-service'
 
export async function createProductAction(formData: FormData) {
  const actor = await requireActor()
  await createProduct(actor, {
    name: formData.get('name'),
    price: formData.get('price'),
    category: formData.get('category'),
  })
  revalidatePath('/products')
}
Code
// Route Handler korzysta z tego samego serwisu
import { createProduct } from '@/lib/services/product-service'
 
export async function POST(request: Request) {
  const actor = await requireApiActor(request)
  const input: unknown = await request.json()
  const product = await createProduct(actor, input)
  return Response.json(product, { status: 201 })
}

Warstwa wspólna powinna egzekwować niezmienne reguły bezpieczeństwa i biznesu, nawet jeśli adapter wcześniej wykonał własną kontrolę. Adapter odpowiada za transport, czyli parsowanie FormData lub JSON, mapowanie błędów na stan formularza albo status HTTP, cookies i nagłówki. Serwis odpowiada za uprawnienia do operacji, reguły domenowe, transakcję i minimalny DTO. Dzięki temu dodanie trzeciego wejścia — np. zadania kolejki — nie omija zasad tylko dlatego, że nie przechodzi przez UI.

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

Często zadawane pytania

Czy Server Actions mogą zwracać dane jak GET endpoint?

Mogą zwracać dane serializowalne przez React, ale są projektowane do mutacji i transportowo używają POST. Klient obecnie wysyła i oczekuje na Actions pojedynczo, więc nie zastępują równoległego data fetchingu. Do pobierania danych w Server Components używaj bezpośrednich wywołań funkcji (np. db.product.findMany()), nie potrzebujesz ani Route Handlera, ani Server Action.

Czy mogę wywołać Server Action z zewnętrznej aplikacji?

Technicznie tak (to POST request), ale nie jest to wspierane ani zalecane. Server Actions generują tymczasowe identyfikatory, które mogą się zmieniać między buildami. Dla zewnętrznych konsumentów — aplikacji mobilnej, innego serwisu — stwórz Route Handler.

Czy Server Actions działają bez JavaScript?

Formularz renderowany przez Server Component i przekazujący akcję przez action={serverAction} korzysta z progresywnego ulepszania: może zostać wysłany przed załadowaniem JS albo przy wyłączonym JS. W Client Component submit przed hydratacją jest kolejkowany i priorytetyzuje hydratację. Wywołanie akcji z onClick lub useEffect wymaga JavaScriptu.

Czy Server Action jest prywatny, bo napisałem go w pliku use server?

Nie. Server Action jest wywoływany przez POST na automatycznie generowanym endpoincie, więc można go uruchomić z zewnątrz. Zawsze waliduj input i sprawdzaj autoryzację w samej akcji i traktuj ją jak publiczny endpoint API pod względem bezpieczeństwa.

Jak nie duplikować logiki między Route Handlerem a Server Action?

Wydziel logikę biznesową do wspólnego serwisu (np. lib/services/product-service.ts), a Route Handler i Server Action niech tylko go wywołują. Dzięki temu reguły biznesowe, autoryzacja zasobu i operacje na bazie żyją w jednym miejscu. Każdy adapter nadal parsuje swój format wejścia: JSON w Route Handlerze albo FormData w Server Action.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend 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 rozwijam własne projekty.

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

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
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

Pages Router do App Router: Migracja w Next.js

Masz działający projekt na Pages Router i chcesz wejść w Server Components, streaming albo Server Actions. Tylko że przepisanie wszystkiego naraz to proszenie się o regresję, więc tego lepiej nie robić. Rozwiązaniem jest Pages i App Router obok siebie w jednym repozytorium i przenoszenie strona po stronie, potem testy i bezpieczne deploye.

Maciej Sala

Maciej Sala

Founder StriveLab

React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce

React 19 pozwala przekazać funkcję bezpośrednio do atrybutu action elementu . Dzięki integracji z Actions, useActionState , useFormStatus i useOptimistic możesz ograniczyć ręczną obsługę onSubmit , pending state oraz optymistycznych aktualizacji , zachowując kontrolę nad błędami i stanem trwałym.

Maciej Sala

Maciej Sala

Founder StriveLab