🔌 Chapter 13: Route Handlers
Building real HTTP endpoints when a Server Action isn't the right shape — webhooks, public APIs, streaming, and file responses.
📖 Plain English Explanation
Server Actions cover mutations initiated by your own UI. But some things genuinely need an HTTP endpoint:
- Stripe needs a URL to POST payment events to
- Your mobile app needs a JSON API
- A cron job needs something to hit
- You need to return a PDF, an image, or a CSV
- You need to stream tokens from an LLM
A Route Handler is a route.ts file that exports functions named after HTTP methods. That's the entire concept.
// app/api/health/route.ts
export async function GET() {
return Response.json({ status: 'ok' })
}
Visit /api/health and you get {"status":"ok"}.
The key thing to internalize: Route Handlers use the Web platform Request and Response objects — the same ones you use with fetch in a browser. No custom framework abstractions to learn.
🛣️ The basics
app/api/users/route.ts → /api/users
app/api/users/[id]/route.ts → /api/users/123
app/rss.xml/route.ts → /rss.xml
The file must be named route.ts (or .js), and cannot live in the same folder as a page.tsx — they'd claim the same URL.
Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Anything else returns 405.
// app/api/posts/route.ts
import { db } from '@/lib/db'
export async function GET() {
const posts = await db.post.findMany()
return Response.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
const post = await db.post.create({ data: body })
return Response.json(post, { status: 201 })
}
// app/api/posts/route.js
import { db } from '@/lib/db'
export async function GET() {
const posts = await db.post.findMany()
return Response.json(posts)
}
export async function POST(request) {
const body = await request.json()
const post = await db.post.create({ data: body })
return Response.json(post, { status: 201 })
}
Note there's no app/api/ requirement — that's just convention. app/rss.xml/route.ts serving /rss.xml works fine.
📥 Reading the request
Query parameters
// app/api/search/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') ?? ''
const page = Number(searchParams.get('page') ?? 1)
const results = await search(query, page)
return Response.json({ results, page })
}
// app/api/search/route.js
export async function GET(request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') ?? ''
const page = Number(searchParams.get('page') ?? 1)
const results = await search(query, page)
return Response.json({ results, page })
}
Route Handlers don't receive a searchParams prop — parse request.url yourself.
Dynamic segments
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
context: RouteContext<'/api/posts/[id]'>
) {
const { id } = await context.params // ← Promise in Next.js 16
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(post)
}
// app/api/posts/[id]/route.js
export async function GET(request, context) {
const { id } = await context.params
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(post)
}
⚠️ Changed in Next.js 16
paramsin Route Handlers is a Promise, same as in pages.ts// ❌ Next.js 14 export async function GET(request, { params }) { const id = params.id } // ✅ Next.js 16 export async function GET(request, context: RouteContext<'/api/posts/[id]'>) { const { id } = await context.params }
Body
const json = await request.json() // JSON
const text = await request.text() // raw string
const form = await request.formData() // multipart / urlencoded
const buffer = await request.arrayBuffer() // binary
You can only read the body once. If you need it twice (common with webhook signature verification), read it as text and parse manually:
const raw = await request.text()
verifySignature(raw, signature)
const body = JSON.parse(raw)
Headers and cookies
Two ways — the request object, or the Next.js helpers:
// app/api/me/route.ts
import { cookies, headers } from 'next/headers'
export async function GET(request: Request) {
// From the request
const auth = request.headers.get('authorization')
// Or via the helpers (async in Next.js 16)
const cookieStore = await cookies()
const session = cookieStore.get('session')?.value
const headerList = await headers()
const ua = headerList.get('user-agent')
return Response.json({ session: !!session })
}
NextRequest — the extended version
Next.js adds a NextRequest with conveniences over the standard Request:
// app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q') // parsed URL
const path = request.nextUrl.pathname
const token = request.cookies.get('token')?.value // typed cookie access
return NextResponse.json({ query, path })
}
// app/api/example/route.js
import { NextResponse } from 'next/server'
export async function GET(request) {
const query = request.nextUrl.searchParams.get('q')
const path = request.nextUrl.pathname
const token = request.cookies.get('token')?.value
return NextResponse.json({ query, path })
}
Use plain Request/Response when the standard API is enough. Reach for NextRequest/NextResponse when you want nextUrl or cookie helpers.
📤 Returning responses
JSON
return Response.json({ ok: true })
return Response.json({ error: 'Bad request' }, { status: 400 })
return Response.json(data, {
status: 200,
headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' },
})
Plain text, XML, anything
// app/rss.xml/route.ts
import { getPosts } from '@/lib/posts'
export async function GET() {
const posts = await getPosts()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Acme Blog</title>
<link>https://acme.com</link>
${posts
.map(
(p) => `<item>
<title>${escapeXml(p.title)}</title>
<link>https://acme.com/blog/${p.slug}</link>
<pubDate>${new Date(p.createdAt).toUTCString()}</pubDate>
</item>`
)
.join('')}
</channel>
</rss>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
},
})
}
// app/rss.xml/route.js
import { getPosts } from '@/lib/posts'
export async function GET() {
const posts = await getPosts()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Acme Blog</title>
${posts.map((p) => `<item><title>${escapeXml(p.title)}</title></item>`).join('')}
</channel>
</rss>`
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
})
}
Files
// app/api/invoices/[id]/route.ts
export async function GET(
request: Request,
context: RouteContext<'/api/invoices/[id]'>
) {
const { id } = await context.params
const session = await verifySession()
if (!session) return new Response('Unauthorized', { status: 401 })
const invoice = await db.invoice.findUnique({ where: { id } })
if (!invoice || invoice.userId !== session.userId) {
return new Response('Not found', { status: 404 })
}
const pdf = await generatePdf(invoice)
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${id}.pdf"`,
},
})
}
Redirects
import { redirect } from 'next/navigation'
export async function GET() {
redirect('/new-location')
}
// or explicitly
return Response.redirect(new URL('/new-location', request.url), 307)
Setting cookies
// app/api/login/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const { email, password } = await request.json()
const session = await authenticate(email, password)
if (!session) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const response = NextResponse.json({ ok: true })
response.cookies.set('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return response
}
// app/api/login/route.js
import { NextResponse } from 'next/server'
export async function POST(request) {
const { email, password } = await request.json()
const session = await authenticate(email, password)
if (!session) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const response = NextResponse.json({ ok: true })
response.cookies.set('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return response
}
httpOnly and sameSite are not optional for session cookies.
🪝 Webhooks — the canonical use case
This is what Route Handlers are for. A Stripe webhook, done properly:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const secret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature')
if (!signature) {
return new Response('Missing signature', { status: 400 })
}
// Read as TEXT — signature verification needs the exact raw bytes
const raw = await request.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(raw, signature, secret)
} catch (err) {
console.error('Invalid webhook signature', err)
return new Response('Invalid signature', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
await db.order.update({
where: { stripeSessionId: session.id },
data: { status: 'paid' },
})
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object
await db.subscription.update({
where: { stripeId: sub.id },
data: { status: 'cancelled' },
})
break
}
default:
console.log('Unhandled event type', event.type)
}
// Always 200 quickly, or the provider retries
return Response.json({ received: true })
}
// app/api/webhooks/stripe/route.js
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const secret = process.env.STRIPE_WEBHOOK_SECRET
export async function POST(request) {
const signature = request.headers.get('stripe-signature')
if (!signature) return new Response('Missing signature', { status: 400 })
const raw = await request.text()
let event
try {
event = stripe.webhooks.constructEvent(raw, signature, secret)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
await db.order.update({
where: { stripeSessionId: event.data.object.id },
data: { status: 'paid' },
})
}
return Response.json({ received: true })
}
Four rules for webhooks:
- Verify the signature. The URL is public; anyone can POST to it.
- Read the raw body as text. Parsing JSON first breaks signature verification.
- Respond fast. Providers time out and retry. Offload slow work with
after(). - Be idempotent. Retries are normal — the same event will arrive twice.
For slow processing:
import { after } from 'next/server'
export async function POST(request: Request) {
const event = verifyAndParse(await request.text())
after(async () => {
await sendReceiptEmail(event) // runs after the 200 is sent
await syncToWarehouse(event)
})
return Response.json({ received: true })
}
🌊 Streaming responses
For LLM output or long-running progress:
// app/api/chat/route.ts
export async function POST(request: Request) {
const { prompt } = await request.json()
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
for await (const chunk of callModel(prompt)) {
controller.enqueue(encoder.encode(chunk))
}
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
}
// app/api/chat/route.js
export async function POST(request) {
const { prompt } = await request.json()
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
for await (const chunk of callModel(prompt)) {
controller.enqueue(encoder.encode(chunk))
}
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
Consumed on the client:
'use client'
import { useState } from 'react'
export function Chat() {
const [output, setOutput] = useState('')
async function send(prompt: string) {
setOutput('')
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
setOutput((prev) => prev + decoder.decode(value))
}
}
return <div>{output}</div>
}
Streaming is the clearest case where a Route Handler beats a Server Action — actions return a single value, not a stream.
⚙️ Caching and segment config
By default, GET handlers with no dynamic data may be cached. Anything reading request, cookies(), or headers() is automatically dynamic.
// app/api/time/route.ts
export const dynamic = 'force-dynamic' // never cache
export const revalidate = 60 // or: cache for 60 seconds
export const maxDuration = 30 // seconds before timeout
export async function GET() {
return Response.json({ now: Date.now() })
}
For public data, an explicit Cache-Control header is usually clearer:
return Response.json(data, {
headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' },
})
🌐 CORS
Route Handlers are same-origin by default. To allow cross-origin calls, handle OPTIONS and set headers:
// app/api/public/route.ts
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://trusted-partner.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 })
}
export async function GET() {
const data = await getPublicData()
return Response.json(data, { headers: corsHeaders })
}
Don't reflexively use '*'. Name the origins you actually trust.
🔐 Authentication
Route Handlers do not go through your layouts, so a layout auth check protects nothing here.
// app/api/admin/users/route.ts
import { verifySession } from '@/lib/dal'
export async function GET() {
const session = await verifySession()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role !== 'admin') {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
const users = await db.user.findMany({
select: { id: true, email: true, role: true },
})
return Response.json(users)
}
For token-based APIs (mobile apps, partners):
export async function GET(request: Request) {
const auth = request.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) {
return Response.json({ error: 'Missing token' }, { status: 401 })
}
const user = await verifyApiToken(auth.slice(7))
if (!user) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
return Response.json(await getDataFor(user))
}
🆚 Route Handler or Server Action?
Server Action when:
✅ Your own UI is submitting a form
✅ A button in your app mutates data
✅ You want progressive enhancement
✅ You'd otherwise write an endpoint only your own UI calls
Route Handler when:
✅ An external service calls you (webhooks, cron)
✅ A mobile app or third party needs an API
✅ You need GET, PUT, DELETE, or custom headers/status
✅ You're returning a file, image, XML, or CSV
✅ You're streaming a response
✅ You need CORS
The most common mistake in App Router codebases is writing a Route Handler that only the app's own frontend calls. If it's your UI talking to your server, that's a Server Action.
⚠️ Common Pitfalls
1. route.ts next to page.tsx
app/api/users/
├── page.tsx ❌
└── route.ts ❌
They claim the same URL. Move one.
2. Reading the body twice
// ❌ TypeError: body already read
const text = await request.text()
const json = await request.json()
// ✅
const text = await request.text()
const json = JSON.parse(text)
3. Not verifying webhook signatures
The endpoint is public. Without verification, anyone can mark orders as paid.
4. Parsing a webhook body before verifying
// ❌ signature check will fail — JSON round-trip changes the bytes
const body = await request.json()
verify(JSON.stringify(body), signature)
// ✅
const raw = await request.text()
verify(raw, signature)
5. Forgetting await on params
Route Handler params is a Promise in Next.js 16, same as pages.
6. Doing slow work before responding to a webhook
Providers time out at a few seconds and retry. Use after() for anything slow.
7. Returning raw errors
// ❌ leaks stack traces, queries, connection strings
catch (error) {
return Response.json({ error: String(error) }, { status: 500 })
}
// ✅
catch (error) {
console.error(error)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
8. Building an API for your own frontend
// ❌ app/api/posts/route.ts, called only by app/posts/page.tsx
// ✅ just query the database in the Server Component
9. Assuming layout auth applies
It doesn't. Check the session inside every handler.
🏋️ Mini Practice Problems
Problem 1: Fix the webhook
export async function POST(request: Request) {
const event = await request.json()
await processPayment(event) // takes 8 seconds
await sendEmails(event) // takes 4 seconds
return Response.json({ ok: true })
}
Four problems. Name and fix them all.
Problem 2: Action or handler?
- A. A contact form on your marketing site
- B. A GitHub webhook for CI status
- C. A "download my data as CSV" button
- D. An admin deleting a user
- E. Your React Native app fetching a product list
- F. Streaming an AI-generated summary
- G. A nightly cron that cleans up expired sessions
Problem 3: Security review
// app/api/users/[id]/route.ts
export async function DELETE(request: Request, context) {
const { id } = await context.params
await db.user.delete({ where: { id } })
return Response.json({ deleted: id })
}
What's wrong? Rewrite it.
Problem 4: Build it
An endpoint at /api/export that:
- Requires a valid session
- Accepts
?format=csv|json - Returns the user's own orders only
- Sets a filename download header
- Is rate-limited to 3 requests per minute
- Returns proper status codes for every failure mode
💼 Interview Notes
Common Questions
Q: What is a Route Handler?
A route.ts file exporting functions named after HTTP methods. Each receives a Web Request and returns a Web Response. It's the App Router replacement for pages/api.
Q: When would you use a Route Handler instead of a Server Action? When something outside your UI needs the endpoint: webhooks, mobile apps, third parties, cron jobs. Also for non-POST methods, file and XML responses, streaming, and custom CORS or cache headers.
Q: How do you handle a webhook safely?
Verify the signature against the raw text body before parsing. Respond 200 quickly and move slow work into after(). Make the handler idempotent, because providers retry.
Q: Why must you read a webhook body as text rather than JSON? Signature verification hashes the exact bytes that were sent. Parsing to JSON and re-stringifying changes whitespace and key order, so the hash won't match.
Q: Do Route Handlers go through layouts or proxy?
Not layouts — a layout auth check protects nothing. They do go through proxy.ts, which is where you can apply cross-cutting checks.
Q: Response or NextResponse?
Response is the Web standard and enough for most cases. NextResponse adds cookies.set() and other conveniences; NextRequest adds nextUrl and typed cookie access.
Q: How is caching handled?
GET handlers can be cached, but reading request, cookies(), or headers() makes them dynamic automatically. Control it with export const dynamic/revalidate, or by setting Cache-Control headers directly.
🏢 Asked at Companies
- Stripe: "Implement a webhook receiver. Walk me through every security consideration."
- Vercel: "When is a Route Handler the wrong tool, and what would you write instead?"
- OpenAI: "Stream tokens from a model to the browser. Show both ends."
- Shopify: "Design a public API endpoint for partners — auth, CORS, rate limiting, caching."
📊 Visual Memory Aid
FILE → URL
app/api/users/route.ts → /api/users
app/api/users/[id]/route.ts → /api/users/:id
app/rss.xml/route.ts → /rss.xml
app/sitemap.xml/route.ts → /sitemap.xml
⚠️ route.ts and page.tsx cannot share a folder
REQUEST → RESPONSE
export async function POST(request, context) {
await request.json() body (once only!)
await request.text() raw body (webhooks)
await request.formData() uploads
request.headers.get(...) headers
await context.params dynamic segments (Promise!)
new URL(request.url) query string
return Response.json(data, { status, headers })
}
ACTION vs HANDLER
┌─────────────────────┬──────────┬──────────┐
│ │ Action │ Handler │
├─────────────────────┼──────────┼──────────┤
│ Your own form │ ✅ │ ⚠️ │
│ Webhook │ ❌ │ ✅ │
│ Mobile / 3rd party │ ❌ │ ✅ │
│ GET / DELETE verbs │ ❌ │ ✅ │
│ File / XML response │ ❌ │ ✅ │
│ Streaming │ ❌ │ ✅ │
│ Works without JS │ ✅ │ ❌ │
└─────────────────────┴──────────┴──────────┘
WEBHOOK CHECKLIST
□ verify signature against the RAW text body
□ respond 200 fast (< 1s)
□ move slow work into after()
□ handle duplicate deliveries idempotently
□ log unhandled event types, don't 500 on them
🎯 Key Takeaways
route.tsexports HTTP method functions and uses the standard WebRequest/Response— no framework-specific API to learn.- Use handlers for external consumers: webhooks, mobile apps, cron, files, streaming, CORS. Use Server Actions for your own UI.
paramsis a Promise here too in Next.js 16 —await context.params, typed withRouteContext<'/api/...'>.- Webhooks need the raw text body for signature verification, a fast 200, and idempotent handling.
after()moves slow work off the critical path. - Route Handlers bypass layouts entirely. Every handler authenticates and authorizes itself, or it's unprotected.
Next Chapter: Caching & use cache →
Practice: Build a webhook receiver with signature verification, a public JSON API with token auth and CORS, and an RSS feed at /rss.xml. Test the webhook with an intentionally wrong signature and confirm it's rejected.