Route Handlers w Next.js 16: Kompletny przewodnik po REST API w App Router (2026)

Praktyczny przewodnik po Route Handlers w Next.js 16: jak zbudować REST API w App Router, skonfigurować cache i CORS, obsłużyć dynamiczne segmenty i kiedy wybrać Route Handlers zamiast Server Actions. Z działającym kodem TypeScript.

Route Handlers Next.js 16: Przewodnik 2026

Zaktualizowano: 1 sierpnia 2026

Route Handlers to sposób, w jaki App Router w Next.js 16 pozwala zdefiniować własne endpointy HTTP przy użyciu standardowych Web API Request i Response. Zamiast pliku pages/api/* tworzysz plik app/api/<segment>/route.ts i eksportujesz funkcje o nazwach metod HTTP (GET, POST, PUT, DELETE, PATCH). Poniżej znajdziesz kompletny przewodnik z działającym kodem, konfiguracją cache w Next.js 16, obsługą CORS i porównaniem z Server Actions.

  • Route Handlers zastąpiły pages/api. Tworzysz plik app/api/<path>/route.ts i eksportujesz nazwane funkcje HTTP zwracające Response.
  • W Next.js 16 pamięć podręczna fetch jest domyślnie wyłączona (opt-in). Aby buforować odpowiedź, ustaw export const dynamic = 'force-static' lub użyj unstable_cache.
  • Dynamiczne segmenty przekazywane są w drugim argumencie: { params } jako obietnica, którą musisz await. To zmiana z Next.js 15.
  • Helpery cookies() i headers() z next/headers są asynchroniczne i działają wewnątrz handlera.
  • Wybieraj Route Handlers do publicznych API, webhooków i endpointów konsumowanych przez klientów spoza aplikacji; Server Actions do mutacji z własnego frontendu.
  • Obsługa CORS wymaga samodzielnego ustawienia nagłówków. Next.js nie robi tego za Ciebie w route.ts.

Czym są Route Handlers w Next.js 16

Route Handlers to funkcje po stronie serwera przypisane do jednego adresu URL, w których obsługujesz żądanie HTTP i zwracasz standardowy obiekt Response. Plik route.ts zawsze siedzi wewnątrz folderu segmentu (np. app/api/products/route.ts) i nie może współistnieć w tym samym folderze z plikiem page.tsx. Segment jest albo stroną, albo endpointem, nigdy jednym i drugim. Handler może eksportować kilka metod HTTP jednocześnie; wszystko, co nie zostało wyeksportowane, zwraca automatycznie 405 Method Not Allowed.

Historycznie Next.js miał pages/api/* z sygnaturą (req, res) pochodzącą z Node.js/Express. App Router idzie w drugą stronę: przyjmujesz i zwracasz obiekty ze specyfikacji Web Fetch, co oznacza że ten sam kod można w dużej mierze przenieść na Cloudflare Workers, Deno albo Bun. Szczerze? Piszę w Next.js jeszcze od czasów getInitialProps i to jedna z tych zmian, które faktycznie ułatwiają życie. Testy jednostkowe handlera nie potrzebują już mockowanego res.status().json(). Po prostu wywołujesz funkcję i sprawdzasz zwrócony Response.

Konwencja jest ścisła: eksport nazywa się dokładnie jak metoda HTTP wielkimi literami. Dozwolone są GET, POST, PUT, PATCH, DELETE, HEAD i OPTIONS. Cała reszta zostaje zignorowana, a klient dostanie 405.

Pierwszy endpoint krok po kroku

Załóżmy prosty katalog produktów. Utwórz plik app/api/products/route.ts:

// app/api/products/route.ts
import { NextRequest } from 'next/server'

type Product = { id: string; name: string; price: number }

const db: Product[] = [
  { id: '1', name: 'Klawiatura', price: 349 },
  { id: '2', name: 'Mysz', price: 129 },
]

export async function GET() {
  return Response.json(db, { status: 200 })
}

export async function POST(request: NextRequest) {
  const body = await request.json()

  if (!body?.name || typeof body.price !== 'number') {
    return Response.json(
      { error: 'Pola name i price są wymagane' },
      { status: 400 },
    )
  }

  const product: Product = {
    id: crypto.randomUUID(),
    name: body.name,
    price: body.price,
  }
  db.push(product)

  return Response.json(product, { status: 201 })
}

Uruchom next dev i wywołaj endpoint: curl http://localhost:3000/api/products. Zwrócenie Response.json(...) automatycznie ustawia nagłówek content-type: application/json. Jeśli chcesz zwrócić tekst albo HTML, użyj new Response('...', { status: 200 }) i ustaw nagłówki ręcznie.

Dynamiczne segmenty i parametry zapytania

Aby obsłużyć trasę typu /api/products/123, umieść plik w folderze z nawiasami klamrowymi: app/api/products/[id]/route.ts. W Next.js 15 i wyżej drugi argument przekazuje params jako obietnicę, którą trzeba rozpakować:

// app/api/products/[id]/route.ts
import { NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  const product = await getProductById(id)

  if (!product) {
    return Response.json({ error: 'Nie znaleziono' }, { status: 404 })
  }

  return Response.json(product)
}

export async function DELETE(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  await deleteProduct(id)
  return new Response(null, { status: 204 })
}

Parametry zapytania (query string) czytasz z request.nextUrl.searchParams. To standardowy URLSearchParams, więc get(), getAll() i has() działają jak w przeglądarce:

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get('q') ?? ''
  const page = Number(request.nextUrl.searchParams.get('page') ?? '1')
  const results = await searchProducts({ q, page })
  return Response.json({ q, page, results })
}

Cache i rewalidacja w Next.js 16

To jedna z największych zmian, na którą starzy użytkownicy powinni uważać. W Next.js 14 i wcześniejszych GET handler był domyślnie statyczny. Next buforował odpowiedź na build i serwował ją bez wywoływania handlera. W Next.js 15 zmieniono to na dynamiczne domyślnie, a w Next.js 16 wraz z tzw. Cache Components pamięć podręczna fetch jest opt-in: musisz świadomie ją włączyć.

Osobiście złapałem się na tym niedawno przy migracji z 14. Endpoint kursów walut serwował ten sam JSON przez dwie godziny po deploy'u, bo zapomniałem dodać dyrektyw cache. Aby utrzymać stare, statyczne zachowanie, dodaj eksport konfiguracji segmentu:

// app/api/products/route.ts
export const dynamic = 'force-static'
export const revalidate = 60 // sekund

export async function GET() {
  const data = await fetchAllProducts()
  return Response.json(data)
}

Dla bardziej granularnej kontroli użyj unstable_cache wewnątrz handlera i przypnij tagi, które później rewalidujesz z Server Action:

import { unstable_cache, revalidateTag } from 'next/cache'

const getCachedProducts = unstable_cache(
  async () => fetchAllProducts(),
  ['products-list'],
  { revalidate: 300, tags: ['products'] },
)

export async function GET() {
  return Response.json(await getCachedProducts())
}

Więcej o strategiach cache i rewalidacji tagów opisałem w tekście o Partial Prerendering w Next.js 16. Koncepty cacheComponents stamtąd bezpośrednio się tu przenoszą. Oficjalne wyjaśnienie znajdziesz też w dokumentacji Route Handlers na nextjs.org.

Cookies, nagłówki i przekierowania

Wewnątrz handlera masz dwie ścieżki do ciasteczek: request.cookies (tylko odczyt bieżącego żądania) oraz asynchroniczny cookies() z next/headers, który pozwala także ustawiać ciasteczka w odpowiedzi:

import { cookies, headers } from 'next/headers'

export async function POST() {
  const cookieStore = await cookies()
  const headerStore = await headers()

  const csrf = headerStore.get('x-csrf-token')
  cookieStore.set('session', 'abc123', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  })

  return Response.json({ ok: true, csrfSeen: Boolean(csrf) })
}

Do przekierowań używaj funkcji redirect() z next/navigation, albo permanentRedirect() dla stałych 308. Pod spodem rzucają wyjątek, więc kod po nich nie zostanie wykonany. Do zwrócenia zwykłego 302 wystarczy jednak natywne Response.redirect(new URL('/login', request.url), 302).

Jak obsłużyć CORS w Route Handlers

Next.js nie ustawia nagłówków CORS za Ciebie w plikach route.ts. Jeśli Twoje API ma być wywoływane z innej domeny, musisz sam dodać Access-Control-Allow-* i obsłużyć preflight:

// app/api/public/route.ts
const CORS = {
  'Access-Control-Allow-Origin': 'https://twoj-frontend.pl',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
}

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: CORS })
}

export async function GET() {
  return Response.json({ hello: 'świat' }, { headers: CORS })
}

Jeśli takich handlerów masz dużo, wyciągnij logikę do middleware Next.js i tam dodawaj nagłówki na wszystkie żądania pasujące do wzorca /api/public/*. Middleware wykonuje się przed handlerem, więc możesz z niego bezpośrednio zwrócić NextResponse na preflight, bez replikowania OPTIONS w każdym pliku.

Route Handlers vs Server Actions: co wybrać

To pytanie zadaje mi każda osoba przechodząca z pages routera. Krótko: Server Actions są dla mutacji wywoływanych z Twojego własnego frontendu w tej samej aplikacji. Route Handlers są dla wszystkiego innego (zewnętrznych klientów, mobilek, webhooków, cronów, publicznych REST/JSON API).

KryteriumRoute HandlersServer Actions
ProtokółStandard HTTP, dowolna metodaPOST z encoded RSC payloadem
KonsumentDowolny klient (cURL, mobile, inny serwis)Twój własny React w tej samej aplikacji
Wywołaniefetch('/api/...') lub SDKBezpośrednie wywołanie funkcji w JSX
Typy end-to-endMusisz sam pilnować (Zod, tRPC)Automatycznie z TypeScriptem
CacheKonfigurujesz revalidate / dynamicRewalidacja przez revalidatePath/Tag
WebhookiTak, naturalny wybórNie, brak dowolnej metody
Progresywne wzbogacanie <form>Wymaga JS na kliencieDziała bez JS

W praktyce najczęściej mam mieszankę: Server Actions dla formularzy w panelu administracyjnym i Route Handlers dla publicznego API oraz webhooków ze Stripe. Głębsze omówienie mutacji z formularzy znajdziesz w moim tekście o Server Actions w Next.js.

Streaming odpowiedzi i Edge Runtime

Route Handlers zwracają standardowy Response, więc możesz wpiąć w niego ReadableStream i strumieniować dane w miarę ich powstawania. Dokładnie tak, jak robią to endpointy LLM-owe zwracające tokeny z Server-Sent Events:

export async function GET() {
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 1; i <= 5; i++) {
        controller.enqueue(encoder.encode(`data: chunk ${i}\n\n`))
        await new Promise((r) => setTimeout(r, 500))
      }
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
    },
  })
}

Aby uruchomić handler na Edge (bliżej użytkownika, ale bez dostępu do Node API), dodaj eksport:

export const runtime = 'edge'

Uwaga na ograniczenia. Edge Runtime bazuje na V8 isolates, więc nie użyjesz pakietów zależnych od fs, net czy natywnych sterowników bazy. Klienci baz danych typu @neondatabase/serverless albo @planetscale/database są zrobione pod ten model. Szczegóły wspieranych API opisano w specyfikacji Fetch API na MDN.

Jak zabezpieczyć endpoint API w Next.js

Podstawy, których pilnuję w każdym projekcie:

  1. Waliduj body i query przez Zod. Nigdy nie ufaj wejściu, nawet jeśli klient jest Twój.
  2. Sprawdzaj sesję na początku handlera, zanim dotkniesz bazy. Auth.js, Clerk czy własny cookie, sprawdź autoryzację jako pierwszy krok.
  3. Ogranicz rozmiar body. request.json() nie ma domyślnego limitu; parsuj strumieniowo albo odrzucaj po nagłówku Content-Length.
  4. Rate limiting. Najprościej z @upstash/ratelimit. Bez tego pierwszy skrypt kiddie z GitHuba położy Ci endpoint (mówię z doświadczenia z jednego projektu w 2024).
  5. Weryfikuj webhooki podpisem HMAC zanim odczytasz body. Stripe, GitHub i Vercel dają Ci sekret, użyj go.
import { z } from 'zod'

const CreateProduct = z.object({
  name: z.string().min(1).max(120),
  price: z.number().int().positive().max(1_000_000),
})

export async function POST(request: Request) {
  const parsed = CreateProduct.safeParse(await request.json())
  if (!parsed.success) {
    return Response.json(
      { error: 'Nieprawidłowe dane', issues: parsed.error.flatten() },
      { status: 422 },
    )
  }
  const created = await createProduct(parsed.data)
  return Response.json(created, { status: 201 })
}

Dodatkowa warstwa uwierzytelniania? API Key w nagłówku dla partnerów B2B, JWT dla mobilnych klientów, ciasteczko sesji dla własnego frontendu. Wzorce middleware'owe (dodawanie nagłówków bezpieczeństwa, blokowanie botów, geolokalizacja) opisałem w przewodniku po Auth.js v5 w Next.js 16. Warto też przejrzeć oficjalne wytyczne bezpieczeństwa w dokumentacji Next.js, szczególnie sekcję o Data Access Layer.

Najczęściej zadawane pytania

Czy Route Handlers zastępują pages/api w Next.js 16?

Tak. Jeśli używasz App Router, wszystkie nowe endpointy piszesz jako route.ts. Stary katalog pages/api nadal działa w projektach hybrydowych, ale nie ma sensu tam pisać nowego kodu. Web Fetch API jest wygodniejsze i przenośne między środowiskami runtime.

Jak zwrócić plik binarny albo obraz z Route Handlera?

Zwróć new Response(buffer, { headers: { 'Content-Type': 'image/png' } }), gdzie buffer to Uint8Array lub ArrayBuffer. Dla dużych plików strumieniuj przez ReadableStream zamiast wczytywać całość do pamięci.

Dlaczego mój GET handler nie jest cachowany w Next.js 16?

Bo domyślne zachowanie się zmieniło. Od Next.js 15 GET jest dynamiczny, a w 16 z Cache Components cache fetch jest opt-in. Ustaw jawnie export const dynamic = 'force-static' oraz export const revalidate = <sekundy>, albo owiń logikę w unstable_cache.

Czy w Route Handlerze mogę użyć Prisma albo Drizzle?

Tak, w domyślnym Node runtime bez ograniczeń. Jeśli przełączasz się na runtime = 'edge', potrzebujesz sterownika kompatybilnego z Edge, np. @prisma/adapter-neon albo drizzle-orm/neon-http. Klasyczne sterowniki na TCP nie zadziałają w V8 isolates.

Jak testować Route Handler jednostkowo?

Importujesz eksport bezpośrednio, tworzysz new Request(url, { method, body }) i sprawdzasz zwrócone Response. Nie potrzebujesz startować serwera. To duża przewaga nad pages/api, gdzie musiałeś mockować NextApiRequest/Response.

Ben Howard
O Autorze Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.