Route Handlers vs Server Actions — kiedy co wybrać i dlaczego to nie to samo

Opublikowano
10 kwietnia 2026
Aktualizacja
25 maja 2026
Czas czytania
4 min czytania

Dwa sposoby na logikę serwerową w App Router

App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera: i . Wyglądają podobnie — oba działają na serwerze, oba sięgają do bazy i zmiennych środowiskowych. I właśnie to podobieństwo myli, bo każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, mści się później na architekturze.

Najczęstszy błąd to traktowanie ich wymiennie. Route Handlers to klasyczne endpointy HTTP. Server Actions to funkcje serwerowe wołane prosto z komponentów React. Ta różnica nie jest kosmetyczna — pociąga za sobą decyzje o architekturze, bezpieczeństwie i wydajności.

Drobne doprecyzowanie terminologii: w dokumentacji React spotkasz dziś nazwę Server Functions. 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 — 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.

Code
// app/api/products/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
 
// GET https://api.example.com/products
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const category = searchParams.get('category')
 
  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    orderBy: { createdAt: 'desc' },
  })
 
  return NextResponse.json(products)
}
 
// POST https://api.example.com/products
export async function POST(request: Request) {
  const body = await request.json()
 
  const product = await db.product.create({
    data: {
      name: body.name,
      price: body.price,
      category: body.category,
    },
  })
 
  return NextResponse.json(product, { status: 201 })
}

Kiedy 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, długie odpowiedzi
  • Custom auth flow — OAuth callbacks, token refresh
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')!
 
  try {
    const event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
 
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object)
        break
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object)
        break
    }
 
    return new Response('OK', { status: 200 })
  } catch (error) {
    return new Response('Webhook error', { status: 400 })
  }
}

Server Actions — funkcje serwerowe w komponentach

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

Code
// app/products/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { z } from 'zod'
 
const productSchema = z.object({
  name: z.string().min(2).max(100),
  price: z.number().positive(),
  category: z.string(),
})
 
export async function createProduct(formData: FormData) {
  const parsed = productSchema.safeParse({
    name: formData.get('name'),
    price: Number(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 })
 
  revalidatePath('/products')
  return { success: true }
}
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 Server Actions?

  • Formularze — CRUD z natywnych formularzy HTML, progresywne ulepszanie
  • Mutacje danych — tworzenie, aktualizacja, usuwanie rekordów
  • Rewalidacja cache — automatyczne odświeżanie stron po mutacji
  • Wewnętrzne operacje — logika dostępna tylko z poziomu UI Next.js
  • Optimistic updates — natychmiastowe UI z rollbackiem

Server Actions z Client Components

Code
'use client'
 
import { useActionState } from 'react'
import { createProduct } from '../actions'
 
export default function ProductForm() {
  const [state, formAction, isPending] = useActionState(createProduct, 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>
  )
}

Porównanie bezpośrednie

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)
DostępnośćPubliczne URLTylko z UI Next.js
FormularzeRęczna obsługaNatywna integracja
RewalidacjaRęcznaAutomatyczna z revalidatePath
StreamingTak (SSE, chunked)Nie
Progresywne ulepszanieNieTak (działają bez JS)
Zewnętrzni konsumenciTakNie

Bezpieczeństwo

Route Handlers — sam zarządzasz autoryzacją

Code
// app/api/admin/users/route.ts
import { auth } from '@/lib/auth'
 
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 są wywoływane przez POST request na automatycznie generowanym endpoincie. To znaczy, że można je wywołać z zewnątrz — nie są prywatne tylko dlatego, że napisałeś je w pliku z "use server".

Code
// actions.ts
'use server'
 
import { auth } from '@/lib/auth'
import { z } from 'zod'
 
export async function deleteProduct(productId: string) {
  // ZAWSZE sprawdzaj autoryzację — action NIE jest prywatny
  const session = await auth()
  if (!session) throw new Error('Unauthorized')
 
  // ZAWSZE waliduj input
  const id = z.string().uuid().parse(productId)
 
  await db.product.delete({ where: { id } })
  revalidatePath('/products')
}

Kluczowa zasada: traktuj Server Actions jak publiczne endpointy pod względem bezpieczeństwa. Waliduj input, sprawdzaj uprawnienia, nie ufaj danym od klienta.

Wzorce architektoniczne

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

Code
// lib/services/product-service.ts
// Logika biznesowa współdzielona między Route Handlers i Server Actions
 
export async function getProducts(filters?: ProductFilters) {
  return db.product.findMany({ where: filters })
}
 
export async function createProduct(data: CreateProductInput) {
  const validated = productSchema.parse(data)
  return db.product.create({ data: validated })
}
Code
// Server Action korzysta z serwisu
'use server'
import { createProduct } from '@/lib/services/product-service'
 
export async function createProductAction(formData: FormData) {
  const data = Object.fromEntries(formData)
  await createProduct(data)
  revalidatePath('/products')
}
Code
// Route Handler korzysta z tego samego serwisu
import { createProduct } from '@/lib/services/product-service'
 
export async function POST(request: Request) {
  const data = await request.json()
  const product = await createProduct(data)
  return Response.json(product, { status: 201 })
}
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?

Nie w tradycyjnym sensie — Server Actions służą do mutacji (POST). 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.

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.

Tak — formularz z action={serverAction} działa jako standardowy HTML POST, także zanim załaduje się JavaScript. To progresywne ulepszanie i fundamentalna przewaga nad podejściem fetch + onClick, które bez JS jest martwe.

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 — traktuj ją jak publiczny endpoint API pod względem bezpieczeństwa.

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 walidacja i operacje na bazie żyją w jednym miejscu, niezależnie od tego, którą warstwą wejściową je uruchomisz.

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 rozwija własne projekty side-projecty.

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

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów