Route Handlers and the Backend-for-Frontend Pattern21 min read
Module F-7·21 min read
Building GET/POST/PUT/DELETE handlers with NextRequest and NextResponse, the new proxy.js convention, userAgent() for device detection, and when to use a Route Handler vs a Server Action.
F-7 — Route Handlers and the Backend-for-Frontend Pattern
Who this is for: Developers who understand Server Components and data fetching from F-3 and F-4, and need to build API endpoints within a Next.js application — for third-party webhooks, mobile clients, public APIs, or proxying external services. This module also clarifies the often-confused question of when to use a Route Handler versus a Server Action.
When You Actually Need a Route Handler
Most data operations in a modern Next.js application don't need a Route Handler. Server Components fetch data directly. Server Actions handle mutations. Between those two primitives, the majority of the client-server communication in your app can happen without you ever writing an HTTP endpoint.
But some situations genuinely require one:
Third-party webhooks. Stripe, GitHub, Shopify, and every other service that sends webhook events needs a publicly accessible HTTP endpoint to POST to. Server Actions can't receive requests from external systems. Route Handlers can.
Public APIs for non-browser clients. If you have a mobile app, a CLI tool, or any consumer that isn't your Next.js frontend, you need a real HTTP API. Route Handlers are that API.
OAuth and auth callbacks. Authentication flows that redirect back to your app with a code or token need a dedicated endpoint. NextAuth handles this internally with Route Handlers under the hood.
Response streaming. Server-Sent Events (SSE), large file downloads, or any response where you need control over the HTTP response body as a stream.
Custom headers or response formats. If a client needs a response in a specific format (CSV export, XML feed, binary data), Route Handlers give you direct control over the Response object.
For everything else — mutations triggered by user interaction in your own frontend, data loading for your own pages — Server Actions and Server Components are the right tools.
The Basics
A route.ts file in any app/ directory folder creates an HTTP endpoint. Export a function named after the HTTP verb:
ts
Any HTTP verb not exported returns 405 Method Not Allowed automatically. Supported verbs: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
Dynamic Route Handlers
Route Handlers work with the same dynamic segment syntax as pages:
ts
params in Route Handlers is also a Promise in Next.js 15 — await it, just like in pages.
Reading the Request
NextRequest extends the standard Web API Request with Next.js-specific utilities:
ts
Building Responses
Use NextResponse for most cases, or the standard Web API Response when you need more control:
ts
Caching Behaviour
GET Route Handlers are cached by default when they return a static response (no dynamic data access). They become dynamic — and bypass the cache — when they:
Read from the incoming Request object (headers, cookies, body)
Use dynamic functions like cookies() or headers()
Use export const dynamic = 'force-dynamic'
ts
Force a Route Handler to always revalidate after a period:
ts
Webhook Handlers — The Real-World Pattern
Webhooks from Stripe, GitHub, or any other service have two requirements: they need to be accessible from the outside (no auth cookie, no session — these are server-to-server requests), and they often need signature verification to ensure the request actually came from the service you expect.
ts
Two things to notice:
Raw body for signature verification.await request.text() gives you the raw request body as a string. This is essential for webhook signature verification — Stripe (and most other services) sign the raw body bytes. If you parse it as JSON first, the bytes change and signature verification fails.
Return 200 quickly. Webhook providers typically require a response within 30 seconds and will retry on timeout or error status. Do your heavy processing asynchronously (queue it, use after() from Next.js 15, or fire a background job) and respond immediately with { received: true }.
Server-Sent Events — Streaming from Route Handlers
Route Handlers can stream responses, which is how you implement Server-Sent Events (SSE) for real-time updates:
ts
tsx
A note on serverless and SSE: long-lived connections like SSE are problematic in serverless environments (Vercel Functions, AWS Lambda) because functions terminate after a response. Vercel's streaming support has improved, but for sustained real-time connections, consider Pusher, Ably, or Upstash for server-less-friendly pub/sub — or self-host on a platform that supports persistent connections. Module A-14 covers this in depth.
Proxying an External API — the BFF Pattern
A common Route Handler use case: your browser calls your own Next.js API, and your server adds a secret API key before forwarding to the real upstream service. The API key never reaches the browser. There's no dedicated declarative file convention for this in Next.js — for a route-scoped proxy, you write the Route Handler directly (for whole-app path rewriting, rewrites() in next.config.ts is the declarative option; see P-10 for configuration):
ts
This is more boilerplate than a one-line config entry, but it's also the honest amount of control you need the moment you want to filter which headers get forwarded, transform the body, add rate limiting, or only proxy specific methods — all of which are just more code in the same handler, not a separate mechanism.
userAgent() — Device Detection
Route Handlers can use userAgent() from next/server to detect the requesting device type — useful for serving different content, redirecting mobile users to a different experience, or A/B testing:
ts
userAgent is more commonly used in Middleware (which you'll cover in P-6) since Middleware runs before every request. In Route Handlers, it's useful when you need device-aware API responses.
CORS Configuration
If your Route Handlers will be called from a different origin (a mobile app, a third-party frontend, Postman during development), you need CORS headers:
ts
ts
The OPTIONS export handles the CORS preflight request. Every other method needs the CORS headers in its response too.
Route Handler vs Server Action — The Decision
The question comes up constantly and the answer is clean once you understand what each tool is for:
Situation
Use
User submits a form in your own Next.js frontend
Server Action
User clicks a button that mutates data
Server Action
External service sends a webhook
Route Handler
Mobile/native app needs an API endpoint
Route Handler
You need to stream a response
Route Handler
You need to return a non-JSON format (CSV, XML)
Route Handler
You need full control over HTTP status/headers
Route Handler
You're building a BFF proxy for an external API
Route Handler
You want to call a mutation from a Client Component
Server Action (no explicit fetch needed)
The rule: Server Actions for your own frontend. Route Handlers for everything else.
Server Actions don't require you to write fetch calls or define endpoint URLs — they're called like functions. Route Handlers require explicit HTTP requests — the caller constructs a URL and makes a fetch call. For internal mutations in your own app, Server Actions win on simplicity. For external consumers, you need the explicit HTTP contract of a Route Handler.
Where We Go From Here
F-8 is the capstone module for the Foundation phase — you'll build a complete content site from scratch using everything covered in F-1 through F-7: the file system, Server Components, data fetching, dynamic routes, built-in components, and Route Handlers. Think of it as a supervised first real application.
After F-8, the Practitioner phase begins. P-1 goes deep on advanced data fetching patterns — the use cache directive, tag-based invalidation at scale, and eliminating the waterfall chains that make apps feel slow. P-2 covers Server Actions properly — not just how to write them but how they're compiled, the security implications, useActionState, optimistic UI, and the after() API.
Knowledge Check
When configuring Server-Sent Events (SSE) in a Route Handler, what specific headers must be returned in the Response?
Your Route Handler proxies requests to a third-party API, attaching a secret API key from an environment variable to each outgoing request. Why does this pattern keep the API key safe from the browser?
According to the module, what is the core rule for deciding between a Server Action and a Route Handler?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// This GET handler is static — can be cachedexportasyncfunctionGET(){const data =awaitgetStaticData();return NextResponse.json(data);}// This GET handler is dynamic — accesses cookies, never cachedexportasyncfunctionGET(request: NextRequest){const session = request.cookies.get('session')?.value;const userData =awaitgetUserData(session);return NextResponse.json(userData);}
exportconst revalidate =60;// revalidate every 60 secondsexportasyncfunctionGET(){const products =awaitgetProducts();return NextResponse.json(products);}
// app/api/webhooks/stripe/route.tsimport{ headers }from'next/headers';import Stripe from'stripe';const stripe =newStripe(process.env.STRIPE_SECRET_KEY!);exportasyncfunctionPOST(request: NextRequest){const body =await request.text();// ← get raw body for signature verificationconst headersList =awaitheaders();const signature = headersList.get('stripe-signature');if(!signature){return NextResponse.json({ error:'No signature'},{ status:400});}let event: Stripe.Event;try{ event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET!);}catch(err){return NextResponse.json({ error:'Invalid signature'},{ status:400});}// Handle the eventswitch(event.type){case'payment_intent.succeeded':awaithandlePaymentSuccess(event.data.object as Stripe.PaymentIntent);break;case'customer.subscription.deleted':awaithandleSubscriptionCancelled(event.data.object as Stripe.Subscription);break;}return NextResponse.json({ received:true});}
// app/api/events/route.tsexportasyncfunctionGET(request: NextRequest){const encoder =newTextEncoder();const stream =newReadableStream({asyncstart(controller){// Send initial data controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type:'connected'})}\n\n`));// Set up a subscription or polling loopconst intervalId =setInterval(async()=>{const update =awaitgetLatestUpdate(); controller.enqueue( encoder.encode(`data: ${JSON.stringify(update)}\n\n`));},2000);// Clean up when client disconnects request.signal.addEventListener('abort',()=>{clearInterval(intervalId); controller.close();});},});returnnewResponse(stream,{ headers:{'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive',},});}