Route Handlers
Intermediateroute.ts gives you a real HTTP endpoint. Server actions replaced most of the reasons to write one, but webhooks, public APIs and file responses still need it.
Overview
Route handlers are the App Router's API routes: a `route.ts` file exporting functions named after HTTP methods. Server components removed the need for them as a data-fetching layer, and server actions removed most of the need for them as a mutation layer, so the honest guidance is that a new app writes far fewer than a Pages Router app did. What is left is genuine: anything a non-browser client calls, anything that must return something other than a React tree, and anything that needs precise control over headers and status.
The Shape
Methods as exports, and the request and response objects.
// app/api/problems/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const topic = req.nextUrl.searchParams.get('topic')
const problems = await db.problem.findMany({ where: { topic } })
return NextResponse.json({ items: problems })
}
export async function POST(req: NextRequest) {
const body = await req.json()
const parsed = CreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'validation_failed', fields: parsed.error.flatten().fieldErrors } },
{ status: 422 },
)
}
const created = await db.problem.create({ data: parsed.data })
return NextResponse.json(created, { status: 201 })
}
// app/api/problems/[slug]/route.ts
export async function GET(req: NextRequest, { params }) {
const { slug } = await params // a Promise in Next 15
const problem = await getProblem(slug)
if (!problem) return new NextResponse('Not found', { status: 404 })
return NextResponse.json(problem)
}
// Any method not exported returns 405 automatically.
// A folder cannot have both page.tsx and route.ts — one URL, one
// handler.When You Actually Need One
The cases a server action does not cover.
// 1. Webhooks — a payment provider posting to you
// app/api/webhooks/razorpay/route.ts
export async function POST(req: NextRequest) {
const raw = await req.text() // RAW body for the signature
const signature = req.headers.get('x-razorpay-signature')
if (!verify(raw, signature, process.env.RAZORPAY_WEBHOOK_SECRET))
return new NextResponse('Invalid signature', { status: 401 })
await enqueue(JSON.parse(raw)) // process async
return NextResponse.json({ received: true }) // answer fast
}
// Verify before parsing, respond quickly, and expect duplicates —
// handling must be idempotent.
// 2. A public API — mobile apps, partners, anything not your browser
// 3. Non-JSON responses — a CSV export, a PDF, an image, a feed
export async function GET() {
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"',
},
})
}
// 4. Streaming a response — proxying an LLM's token stream
// 5. OAuth callbacks, and anything that must set cookies precisely
// 6. Cron targets, health checks, sitemaps built on the fly
// NOT a reason: fetching data for your own server component. Call the
// database or the service directly — an internal fetch to your own
// route is a wasted HTTP round trip.Caching and Protection
The defaults changed, and an unprotected handler is a public endpoint.
// Next 14: GET handlers are cached by default — a real surprise when
// the data is live. Next 15: not cached.
export const dynamic = 'force-dynamic' // always fresh
export const revalidate = 60 // or cache for a minute
// Be explicit either way, so an upgrade does not change behaviour.
// Every handler is public. Authenticate inside it, exactly as an
// action must:
const user = await getCurrentUser()
if (!user) return new NextResponse('Unauthorized', { status: 401 })
// Cron and internal endpoints need a shared secret:
if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`)
return new NextResponse('Unauthorized', { status: 401 })
// Rate limit anything unauthenticated — a public POST with no limit
// is an invitation:
const { success } = await ratelimit.limit(ip)
if (!success) return new NextResponse('Too many requests', { status: 429 })
// CORS only matters if another origin calls it; your own frontend is
// same-origin and needs none:
return NextResponse.json(data, {
headers: { 'Access-Control-Allow-Origin': 'https://partner.example.com' },
})
// and export OPTIONS to answer the preflight.Key Points to Remember
- 1route.ts exports functions named after HTTP methods; unexported methods return 405 automatically
- 2Server components and server actions removed most reasons to write one — what remains is webhooks, public APIs and non-JSON responses
- 3Webhooks need the raw body for signature verification, a fast response, and idempotent handling
- 4GET handlers were cached by default in Next 14 and are not in Next 15 — set the behaviour explicitly
- 5Every route handler is a public endpoint: authenticate inside it and rate-limit anything unauthenticated
Interview Questions
Sign in to ask AriaWhen would you write a route handler instead of a server action?
Why must a webhook handler read the raw body rather than the parsed JSON?
Why is fetching your own route handler from a server component wasteful?
Ask Aria about Route Handlers
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.