Back/Module F-7 Route Handlers and the Backend-for-Frontend Pattern
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 covers the proxy.js convention introduced in Next.js 15 and 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
// app/api/products/route.ts import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { const products = await db.products.findMany({ orderBy: { createdAt: 'desc' }, take: 50, }); return NextResponse.json(products); } export async function POST(request: NextRequest) { const body = await request.json(); // Basic validation if (!body.name || !body.price) { return NextResponse.json( { error: 'name and price are required' }, { status: 400 } ); } const product = await db.products.create({ data: body }); return NextResponse.json(product, { status: 201 }); }

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
// app/api/products/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; interface RouteContext { params: Promise<{ id: string }>; } export async function GET(request: NextRequest, context: RouteContext) { const { id } = await context.params; const product = await db.products.findUnique({ where: { id } }); if (!product) { return NextResponse.json({ error: 'Product not found' }, { status: 404 }); } return NextResponse.json(product); } export async function PATCH(request: NextRequest, context: RouteContext) { const { id } = await context.params; const body = await request.json(); const product = await db.products.update({ where: { id }, data: body, }); return NextResponse.json(product); } export async function DELETE(request: NextRequest, context: RouteContext) { const { id } = await context.params; await db.products.delete({ where: { id } }); return new Response(null, { status: 204 }); }

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
export async function GET(request: NextRequest) { // URL and query string const url = request.nextUrl; const category = url.searchParams.get('category'); const page = parseInt(url.searchParams.get('page') ?? '1'); // Headers const authHeader = request.headers.get('authorization'); const contentType = request.headers.get('content-type'); // Cookies const sessionCookie = request.cookies.get('session')?.value; // Body (POST/PATCH/PUT) const json = await request.json(); // parse as JSON const text = await request.text(); // parse as text const form = await request.formData(); // parse as FormData const buffer = await request.arrayBuffer(); // parse as binary }

Building Responses

Use NextResponse for most cases, or the standard Web API Response when you need more control:

ts
// JSON response return NextResponse.json({ message: 'ok' }, { status: 200 }); // Custom headers return NextResponse.json(data, { headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300', 'X-Custom-Header': 'value', }, }); // No-body response (204) return new Response(null, { status: 204 }); // Text response return new Response('Hello, world', { headers: { 'Content-Type': 'text/plain' }, }); // Binary/file response const fileBuffer = await readFile('./report.pdf'); return new Response(fileBuffer, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename="report.pdf"', }, }); // Redirect return NextResponse.redirect(new URL('/login', request.url));

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
// This GET handler is static — can be cached export async function GET() { const data = await getStaticData(); return NextResponse.json(data); } // This GET handler is dynamic — accesses cookies, never cached export async function GET(request: NextRequest) { const session = request.cookies.get('session')?.value; const userData = await getUserData(session); return NextResponse.json(userData); }

Force a Route Handler to always revalidate after a period:

ts
export const revalidate = 60; // revalidate every 60 seconds export async function GET() { const products = await getProducts(); return NextResponse.json(products); }

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
// 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: NextRequest) { const body = await request.text(); // ← get raw body for signature verification const headersList = await headers(); 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 event switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object as Stripe.PaymentIntent); break; case 'customer.subscription.deleted': await handleSubscriptionCancelled(event.data.object as Stripe.Subscription); break; } return NextResponse.json({ received: true }); }

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
// app/api/events/route.ts export async function GET(request: NextRequest) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { // Send initial data controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`) ); // Set up a subscription or polling loop const intervalId = setInterval(async () => { const update = await getLatestUpdate(); controller.enqueue( encoder.encode(`data: ${JSON.stringify(update)}\n\n`) ); }, 2000); // Clean up when client disconnects request.signal.addEventListener('abort', () => { clearInterval(intervalId); controller.close(); }); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); }
tsx
// Client Component consuming SSE 'use client'; import { useEffect, useState } from 'react'; export function LiveUpdates() { const [updates, setUpdates] = useState<string[]>([]); useEffect(() => { const eventSource = new EventSource('/api/events'); eventSource.onmessage = (event) => { const data = JSON.parse(event.data); setUpdates(prev => [...prev, data]); }; return () => eventSource.close(); }, []); return <ul>{updates.map((u, i) => <li key={i}>{u}</li>)}</ul>; }

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.


The proxy.js Convention — Declarative API Proxying (Next.js 15)

Next.js 15 introduced a proxy.js (or proxy.ts) file convention as a declarative alternative to writing proxy Route Handlers manually.

Place a proxy.js file in any route segment to proxy requests for that path to an external service:

ts
// app/api/github/proxy.ts (or proxy.js) export default { destination: 'https://api.github.com', // Requests to /api/github/** are forwarded to https://api.github.com/** };

With this file in place:

  • GET /api/github/users/octocatGET https://api.github.com/users/octocat
  • GET /api/github/repos/vercel/next.jsGET https://api.github.com/repos/vercel/next.js

You can add headers to outgoing requests (like auth tokens that shouldn't be exposed to the browser):

ts
export default { destination: 'https://api.someservice.com', headers: { Authorization: `Bearer ${process.env.SOMESERVICE_API_KEY}`, 'X-App-Version': '1.0.0', }, // Only forward specific methods methods: ['GET', 'POST'], };

This pattern is the BFF (Backend-for-Frontend) pattern made declarative. Your browser calls your Next.js API. Next.js adds the secret API key and forwards to the real service. The API key never reaches the browser.

Before proxy.js, you'd write this manually:

ts
// The manual version — more flexible but more boilerplate export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params; const upstreamUrl = `https://api.someservice.com/${path.join('/')}`; const response = await fetch(upstreamUrl, { headers: { Authorization: `Bearer ${process.env.SOMESERVICE_API_KEY}`, ...Object.fromEntries(request.headers), }, }); return new Response(response.body, { status: response.status, headers: response.headers, }); }

proxy.js eliminates this boilerplate for the straightforward case.


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
import { userAgent } from 'next/server'; export async function GET(request: NextRequest) { const { device, browser, os, isBot } = userAgent(request); if (isBot) { // Serve simplified content or return early for crawlers return NextResponse.json({ simplified: true }); } if (device.type === 'mobile') { return NextResponse.redirect(new URL('/m/home', request.url)); } return NextResponse.json({ device: device.type }); }

userAgent is more commonly used in Middleware (which you'll cover in P-5) 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
// lib/cors.ts const ALLOWED_ORIGINS = [ 'https://yourapp.com', 'https://staging.yourapp.com', process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '', ].filter(Boolean); export function corsHeaders(origin: string | null) { const isAllowed = origin && ALLOWED_ORIGINS.includes(origin); return { 'Access-Control-Allow-Origin': isAllowed ? origin! : ALLOWED_ORIGINS[0], 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400', }; }
ts
// app/api/products/route.ts import { corsHeaders } from '@/lib/cors'; export async function OPTIONS(request: NextRequest) { const origin = request.headers.get('origin'); return new Response(null, { status: 204, headers: corsHeaders(origin), }); } export async function GET(request: NextRequest) { const origin = request.headers.get('origin'); const products = await getProducts(); return NextResponse.json(products, { headers: corsHeaders(origin) }); }

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:

SituationUse
User submits a form in your own Next.js frontendServer Action
User clicks a button that mutates dataServer Action
External service sends a webhookRoute Handler
Mobile/native app needs an API endpointRoute Handler
You need to stream a responseRoute Handler
You need to return a non-JSON format (CSV, XML)Route Handler
You need full control over HTTP status/headersRoute Handler
You're building a BFF proxy for an external APIRoute Handler (proxy.js)
You want to call a mutation from a Client ComponentServer 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.

Discussion