Route Handlers in Next.js 15: REST API's, Webhooks en Streaming met route.ts

Een praktische gids voor Route Handlers in Next.js 15 App Router. Bouw REST API's, valideer Stripe webhooks en stream LLM-responses met route.ts. Inclusief caching-veranderingen, dynamische params en een productie-checklist.

Bijgewerkt: 30 mei 2026

Route Handlers in Next.js 15 zijn aangepaste HTTP-endpoints die je definieert in een route.ts-bestand binnen de app-directory, en die de standaard Web Request/Response API gebruiken om GET-, POST-, PUT-, PATCH-, DELETE-, HEAD- en OPTIONS-verzoeken af te handelen. Ze vervangen de oude pages/api-routes en zijn de juiste keuze voor publieke REST-API's, webhooks van Stripe of GitHub, streamingreacties en alles wat een echte URL nodig heeft. In deze gids loop ik door de configuratie, caching-veranderingen in Next.js 15, dynamische params, beveiliging en een paar productiepatronen die ik zelf gebruik.

  • Route Handlers leven in app/api/**/route.ts en exporteren één async functie per HTTP-methode (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).
  • In Next.js 15 zijn GET-handlers standaard dynamisch (niet gecachet). Opt-in met export const dynamic = 'force-static' of revalidate.
  • Dynamische params zijn nu een Promise. Je moet await ctx.params doen voordat je id kunt lezen.
  • Gebruik Route Handlers voor publieke API's, webhooks en streaming. Server Actions zijn handiger voor formuliermutaties vanuit je eigen UI.
  • Stripe-webhooks vereisen toegang tot de raw body via request.text() plus signatuurverificatie. Sla nooit deze stap over.
  • Streamingresponses met ReadableStream werken native en zijn de basis van AI-chatfeatures.

Wat zijn Route Handlers in Next.js 15?

Een Route Handler is een serverbestand met de naam route.ts (of route.js) dat ergens in de app-tree zit en één of meer HTTP-methode-functies exporteert. Next.js mapt de bestandspositie op een URL, dus app/api/products/route.ts wordt /api/products, en handelt routing, body parsing en streaming af. Onder de motorkap krijg je de standaard Web Request en Response, met de optionele Next-specifieke NextRequest en NextResponse wrappers die helpers toevoegen voor cookies, geolocatie en JSON-shortcuts.

Voor wie van de pages router komt: dit is het App Router-equivalent van pages/api/*, maar dan zonder de oude req/res Node-objecten en zonder bodyParser-configuratie. Eerlijk gezegd, ik heb jarenlang met req.body en res.json() gewerkt, en moest even wennen aan await request.json() in plaats daarvan. De refactor was het waard. Webhooks en file uploads zijn merkbaar minder werk geworden, omdat je gewoon request.text() of request.formData() aanroept zoals in elke moderne fetch-handler.

Eén regel die je niet kunt overtreden: een segment kan geen page.tsx én een route.ts tegelijk bevatten. Next.js weet anders niet of een GET een HTML-pagina of een JSON-antwoord moet returnen. Conventioneel zet je daarom alle handlers onder app/api/..., hoewel dat geen technische vereiste is.

Je eerste route.ts: GET en POST

Hier is een minimaal voorbeeld dat een lijst producten teruggeeft en een nieuw product accepteert. Plak dit in app/api/products/route.ts:

// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'

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

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const limit = Number(searchParams.get('limit') ?? 20)

  const products = await db.product.findMany({
    take: Math.min(limit, 100),
    orderBy: { createdAt: 'desc' },
  })

  return NextResponse.json({ products })
}

export async function POST(request: NextRequest) {
  const json = await request.json()
  const parsed = ProductInput.safeParse(json)

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid input', issues: parsed.error.flatten() },
      { status: 400 },
    )
  }

  const product = await db.product.create({ data: parsed.data })
  return NextResponse.json({ product }, { status: 201 })
}

Een paar dingen om op te merken. Eén: ik gebruik de officiële route.js-conventie, waarbij elke geëxporteerde async functie een HTTP-methode is. Als iemand een PUT stuurt naar deze URL, antwoordt Next.js automatisch met 405 Method Not Allowed (geen boilerplate nodig). Twee: ik valideer de body altijd met Zod, want de body is gebruikersinvoer en TypeScript-types verdwijnen tijdens runtime. Drie: NextResponse.json() is letterlijk een dunne wrapper rond new Response(JSON.stringify(...), { headers: { 'content-type': 'application/json' }}). Handig, maar geen magie.

Hoe maak je een dynamische API-route met [id]?

Dynamische segmenten werken hetzelfde als bij pages: maak een map [id] aan en plaats daarin een route.ts. De grote verandering in Next.js 15 is dat params nu een Promise is in plaats van een gewoon object. Dit voelt eerst raar, maar het is een bewuste keuze van het Next-team om streaming en partial prerendering beter te ondersteunen. Je await't simpelweg:

// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'

type Context = { params: Promise<{ id: string }> }

export async function GET(_req: NextRequest, { params }: Context) {
  const { id } = await params
  const product = await db.product.findUnique({ where: { id } })

  if (!product) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }
  return NextResponse.json({ product })
}

export async function DELETE(_req: NextRequest, { params }: Context) {
  const { id } = await params
  await db.product.delete({ where: { id } })
  return new Response(null, { status: 204 })
}

De nieuwe globale RouteContext-helper maakt het type-aanduiding nog korter. In plaats van een eigen Context-type kun je ctx: RouteContext<'/products/[id]'> gebruiken. Next.js leidt zelf de juiste param-keys af uit het pad.

Werkt caching nog standaard in Next.js 15 GET handlers?

Nee, en dat is waarschijnlijk de belangrijkste breaking change in Next.js 15 voor wie van 13 of 14 upgradet. In de oude versies werden GET-handlers automatisch gecachet zolang je geen cookies of headers las. Dat leverde verrassend veel verwarring op. Een endpoint dat productdata bevatte, gaf dagen later nog stale resultaten terug omdat er ergens een edge cache stond. Vercel heeft dit teruggedraaid: in 15+ zijn alle GET-handlers dynamisch (uncached) tenzij je expliciet opt-in geeft.

Opt-in kan op drie manieren, afhankelijk van wat je wilt:

// 1. Forceer volledige statische output (build-tijd)
export const dynamic = 'force-static'

// 2. Time-based revalidation (ISR-stijl)
export const revalidate = 60 // seconden

// 3. Cachen per fetch call met tags
const data = await fetch('https://api.example.com/items', {
  next: { tags: ['items'], revalidate: 300 },
})

Voor tag-gebaseerde invalidatie roep je revalidateTag('items') aan vanuit een Server Action of een andere Route Handler nadat data is gewijzigd. Wie dieper wil graven in caching, partial prerendering en de nieuwe use cache-directive: ik heb hier een complete gids over caching in Next.js 15 geschreven die alle lagen uitlegt.

Wanneer gebruik je Route Handlers in plaats van Server Actions?

Dit is misschien wel de meest gestelde vraag in de App Router-community, en het korte antwoord is: Server Actions voor mutaties vanuit je eigen UI, Route Handlers voor alles wat een externe client moet kunnen aanroepen. Server Actions geven je gratis CSRF-bescherming, progressive enhancement en automatische serialisatie, maar ze hebben geen stabiele, publieke URL. Hun endpoint is een gehashte action-ID die elke build kan veranderen.

AspectServer ActionsRoute Handlers
Stabiele publieke URLNeeJa
CSRF-beschermingIngebouwdZelf implementeren
HTTP-controle (status, headers)BeperktVolledig
Streaming response bodyBeperktNative ReadableStream
Geschikt voor webhooksNeeJa
Geschikt voor mobile/3rd-party clientsNeeJa
Cron jobs / scheduled triggersNeeJa
Boilerplate voor formulierenZeer weinigVeel

In de praktijk gebruik je vaak allebei in dezelfde app. Het patroon dat bij mij werkt: zet je domeinlogica in een gedeelde service-module (lib/services/products.ts) en maak zowel je Server Action als je Route Handler tot een dunne transport-wrapper eromheen. Zo werk je niet tweemaal aan dezelfde validatie en kun je dezelfde functie testen zonder een HTTP-laag te mocken. Voor de details over Server Actions zelf (formulieren, useActionState, optimistische updates) verwijs ik naar mijn complete gids over Server Actions en formulieren.

Hoe valideer je een Stripe-webhook signature?

Webhooks zijn waarschijnlijk de duidelijkste use-case voor Route Handlers. Stripe, GitHub, Sanity, Resend, Clerk: al deze diensten POST'en events naar een URL die jij host. En de eerste regel van webhook-beveiliging is: vertrouw nooit de body zonder de signature te verifiëren. Zonder verificatie kan iedereen die je URL ontdekt nep-events sturen en bijvoorbeeld abonnementen activeren of credits aan accounts toevoegen.

Stripe ondertekent elke webhook met een geheim. Om de signature te kunnen herberekenen heb je de exacte raw bytes van de body nodig. Geparseerde JSON werkt niet, omdat veld-volgorde en whitespace anders zijn. In Route Handlers krijg je de raw body simpelweg met await request.text():

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      await fulfillOrder(session)
      break
    }
    case 'customer.subscription.deleted':
      await cancelSubscription(event.data.object as Stripe.Subscription)
      break
  }

  return NextResponse.json({ received: true })
}

Eén productie-tip die ik op de harde manier heb geleerd toen ik mijn eerste betaaltool live zette: maak je handler idempotent. Stripe stuurt webhooks soms meerdere keren door (bij netwerkproblemen of retries) en je wilt niet dezelfde order tweemaal afhandelen. Bewaar de event.id in een tabel en check bij binnenkomst of je hem al hebt verwerkt. Voor uitgebreide info over webhook-beveiliging is de Stripe-documentatie over signatuurverificatie de canonieke bron.

Streaming responses voor AI en SSE

De tweede use-case waar Route Handlers echt schitteren: streaming. Elk modern AI-chatproduct heeft een endpoint dat tokens stuurt zodra het LLM ze genereert. Met een Route Handler hoef je daar geen exotische libraries voor te installeren. Return gewoon een Response met een ReadableStream als body:

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

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

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
      // Vervang dit door je echte LLM-call (OpenAI, Anthropic, etc.)
      const tokens = await callLLM(prompt)
      for await (const token of tokens) {
        controller.enqueue(encoder.encode(token))
      }
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
    },
  })
}

Aan de client-kant lees je de stream met response.body.getReader() of via de Vercel AI SDK, die deze plumbing voor je verbergt. Voor Server-Sent Events stuur je hetzelfde patroon, maar dan met content-type text/event-stream en regels die met data: beginnen en eindigen op \n\n. Vergeet niet om 'Cache-Control': 'no-cache, no-transform' mee te sturen, anders bufferen sommige edge proxies de hele response totdat hij compleet is. Ik hit deze exacte bug toen ik mijn eerste chat-feature shipte: geen streaming, maar één blok output aan het einde.

CORS, Edge Runtime en authenticatie

Drie dingen die regelmatig terugkomen in productie en die ik kort wil aanstippen.

CORS-headers

Als je API door een third-party frontend wordt aangeroepen, moet je CORS-headers terugsturen. Voor een enkele route exporteer je een OPTIONS-handler:

const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://klant-app.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

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

Voor app-brede CORS (bijvoorbeeld als al je /api/* routes hetzelfde gedrag moeten hebben) is middleware schoner. Mijn artikel over middleware in de App Router behandelt dat patroon plus auth-gates en URL-rewrites.

Edge Runtime

Je kunt elke handler op de Edge runtime draaien door export const runtime = 'edge' bovenaan te zetten. Edge handlers booten in milliseconden en draaien in regio's dicht bij de gebruiker, ideaal voor latency-gevoelige endpoints zoals zoekautosuggesties. Het nadeel: geen volledige Node.js API. Geen fs, geen native bindings, beperkte database-clients. Drizzle en Prisma's edge-adapters werken, maar pg-native niet.

Authenticatie

Route Handlers zijn publiek tenzij je ze beveiligt. Check sessies via je auth-library (Auth.js, Clerk, Lucia) bovenaan elke handler die gevoelige data raakt. Voor admin-endpoints gebruik ik vaak een simpele bearer-token check:

const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (token !== process.env.ADMIN_API_TOKEN) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

Voor cron jobs op Vercel gebruik je CRON_SECRET op dezelfde manier. Vercel stuurt het automatisch mee als Authorization: Bearer $CRON_SECRET.

Productie-checklist en valkuilen

Voordat ik een Route Handler naar productie push, loop ik deze lijst af:

  1. Input validatie met Zod of vergelijkbaar op elke POST/PUT/PATCH. Vertrouw nooit op TypeScript-types alleen.
  2. Rate limiting op publieke endpoints. Upstash Ratelimit of Vercel KV werken native, ook op de Edge.
  3. Idempotency keys voor mutaties die door externe clients worden aangeroepen, vooral voor payment- en order-flows.
  4. Logging met een gestructureerde logger (pino, axiom), zodat je in productie kunt zoeken op request-id.
  5. Error responses zonder lekken: stack traces nooit naar de client, alleen een generiek bericht plus een correlation-id.
  6. Caching headers expliciet. Vertrouw niet op defaults, zeker niet sinds Next.js 15 het defaultgedrag heeft omgedraaid.
  7. OPTIONS testen als je CORS gebruikt. Vergeten OPTIONS-handlers zijn een klassieke oorzaak van "werkt in Postman, faalt in de browser".

Een laatste verandering om in de gaten te houden: in Next.js 16 (beta op moment van schrijven) wordt het type van de tweede argument verder gestroomlijnd via de RouteContext-helper, en is er een nieuwe 'use cache'-directive die je per-handler caching geeft zonder export-constants. De officiële Next.js documentatie over Route Handlers wordt continu bijgewerkt en is de beste plek om die changes te volgen.

Veelgestelde vragen

Kan ik Route Handlers en Server Actions in hetzelfde project mixen?

Ja, en in de meeste productie-apps doe je dat ook. Gebruik Server Actions voor formuliermutaties vanuit je eigen UI en Route Handlers voor webhooks, publieke API's en streaming. Zet je business logic in een gedeelde service-module zodat beide transports dezelfde functie aanroepen.

Waarom krijg ik een TypeScript-error bij params.id in Next.js 15?

Sinds Next.js 15 is params een Promise. Je moet const { id } = await params doen voordat je het kunt lezen. Draai npx @next/codemod@latest next-async-request-api . om je hele codebase automatisch te migreren.

Hoe stel ik een Route Handler bloot via een custom domein?

Routing gebeurt op bestandspad: app/api/products/route.ts wordt /api/products op elk domein dat naar je deployment wijst. Voor versioning zet je bestanden in app/api/v1/products/route.ts. Custom domeinen koppel je via je hostingplatform (Vercel, Netlify, eigen Node-server). Next.js doet niets domeinspecifieks.

Werken Route Handlers op de Edge runtime overal?

Ze draaien op elke hosting die Web Standards-runtimes ondersteunt (Vercel Edge, Cloudflare Workers via OpenNext, Netlify Edge). Beperking: geen Node-specifieke API's zoals fs of native database-bindings. Gebruik HTTP-gebaseerde of Edge-geoptimaliseerde clients zoals Neon's serverless driver of Upstash Redis.

Hoe test ik een Route Handler in een unit test?

Importeer de geëxporteerde functie direct (import { POST } from './route') en roep hem aan met een new NextRequest('http://localhost/api/...', { method: 'POST', body: ... }). Met Vitest of Jest werkt dit zonder dev-server te starten. Voor end-to-end testen met echte HTTP gebruik je Playwright tegen next start.

Ben Howard
Over de Auteur Ben Howard

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