Route Handlers: Building Type-Safe API Endpoints
What Route Handlers Are and Why They Exist
The App Router introduced Route Handlers to replace API Routes from the Pages Router. They serve similar purposes but are built on the standard Web API (Request/Response) rather than Node.js-specific req/res objects. This design decision has significant implications: the same code runs on Node.js, the Edge Runtime, or even Deno without modification.
Route Handlers live inside the app/ directory and must be named route.ts (or route.js). The path rules match page routing, but a directory cannot contain both page.tsx and route.ts โ a directory is either a page or an API endpoint, never both.
app/
api/
users/
route.ts โ GET /api/users, POST /api/users
[id]/
route.ts โ GET /api/users/[id], PUT /api/users/[id]
posts/
route.ts
HTTP Method Exports and NextRequest/NextResponse
Route Handlers handle requests by exporting functions named after HTTP methods:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
return NextResponse.json({ users: [] })
}
export async function POST(request: NextRequest) {
const body = await request.json()
// Handle creation logic
return NextResponse.json({ id: '123' }, { status: 201 })
}
Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Unsupported methods automatically return 405 Method Not Allowed.
NextRequest extends the Web standard Request with additional properties: nextUrl (a parsed URL object), cookies (type-safe cookie operations), and geo (geolocation data).
Reading Request Data
Request Body
export async function POST(request: NextRequest) {
// JSON body
const body = await request.json()
// Form data
const formData = await request.formData()
const name = formData.get('name') as string
const file = formData.get('file') as File
// Raw text
const text = await request.text()
// ArrayBuffer (binary)
const buffer = await request.arrayBuffer()
}
Query Parameters and Path Parameters
// app/api/users/[id]/route.ts
interface Context {
params: Promise<{ id: string }>
}
export async function GET(
request: NextRequest,
context: Context
) {
const { id } = await context.params
// Query params: /api/users/123?include=posts&limit=10
const { searchParams } = request.nextUrl
const include = searchParams.get('include')
const limit = parseInt(searchParams.get('limit') ?? '20', 10)
return NextResponse.json({ id, include, limit })
}
In Next.js 15, params is a Promise and must be awaited. This is a significant change from Next.js 14 โ forgetting to await it causes a runtime error.
Request Headers
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization')
const contentType = request.headers.get('content-type')
const userAgent = request.headers.get('user-agent')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
Building Type-Safe APIs with Zod
In production, never trust data from clients. Zod provides runtime validation and automatically infers TypeScript types, making it the ideal companion for Route Handlers:
// app/api/posts/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(10).optional().default([]),
publishedAt: z.string().datetime().optional(),
})
type CreatePostInput = z.infer<typeof CreatePostSchema>
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON' },
{ status: 400 }
)
}
const result = CreatePostSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const data: CreatePostInput = result.data
const post = await db.post.create({
data: {
title: data.title,
content: data.content,
tags: data.tags,
publishedAt: data.publishedAt ? new Date(data.publishedAt) : null,
},
})
return NextResponse.json(post, { status: 201 })
}
safeParse does not throw; it returns either { success: true, data } or { success: false, error }, making error handling explicit and predictable.
Setting Response Headers and Status Codes
export async function GET(request: NextRequest) {
const data = await fetchData()
return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
'X-Custom-Header': 'my-value',
},
})
}
You can also use the standard Response constructor (Next.js supports both):
export async function GET() {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
})
}
Streaming Responses with ReadableStream
For large datasets or AI-generated content, streaming responses dramatically improve user experience โ users see the first bytes much sooner rather than waiting for the entire response:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const items = ['Hello', ' ', 'World', '!']
for (const item of items) {
controller.enqueue(encoder.encode(item))
// Simulate delay (in real use, this would be AI model token output)
await new Promise(resolve => setTimeout(resolve, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
})
}
For Server-Sent Events (SSE) format, suited for real-time push scenarios:
// app/api/events/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
function send(data: object) {
const formatted = `data: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(formatted))
}
send({ type: 'connected', timestamp: Date.now() })
let count = 0
const interval = setInterval(() => {
send({ type: 'heartbeat', count: ++count })
if (count >= 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
Route Handlers vs Server Actions: How to Choose
This is the most common architectural decision question in the App Router:
| Scenario | Route Handlers | Server Actions |
|---|---|---|
| External client calls (mobile apps, third parties) | Yes | No |
| Webhooks (Stripe, GitHub, etc.) | Yes | No |
| File downloads / binary responses | Yes | No |
| Form submissions from a Next.js frontend | No | Yes |
| Data mutations (CRUD) | Works | Simpler |
| Streaming AI responses | Yes | Yes (useActionState) |
The core criterion for Route Handlers: the caller is not your Next.js frontend code. If it's a mobile app, a third-party service, or you need specific HTTP semantics (status codes, Content-Type, streaming), use Route Handlers.
The core criterion for Server Actions: data mutations triggered from React components, especially form submissions. Server Actions automatically handle CSRF protection and integrate seamlessly with React's concurrent features (useFormStatus, useOptimistic).
CORS Configuration
If your Route Handler needs to be called cross-origin (for example, providing a public API), you need to configure CORS headers:
// lib/cors.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
]
export function corsHeaders(origin: string | null) {
const allowedOrigin = ALLOWED_ORIGINS.find(o => o === origin)
return {
'Access-Control-Allow-Origin': allowedOrigin ?? 'null',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
}
// app/api/public/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 data = await getPublicData()
return NextResponse.json(data, {
headers: corsHeaders(origin),
})
}
Preflight requests (OPTIONS) must be handled separately and must return 204 No Content.
Rate Limiting
Route Handlers exposed to the internet must defend against abuse. Use Upstash Redis for stateless distributed rate limiting:
// lib/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
})
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ratelimit } from '@/lib/rateLimit'
export async function GET(request: NextRequest) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.round((reset - Date.now()) / 1000)),
},
}
)
}
const query = request.nextUrl.searchParams.get('q')
const results = await search(query)
return NextResponse.json(results, {
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
},
})
}
Upstash Redis is a serverless-friendly KV store accessible via HTTP API, making it compatible with both the Edge Runtime and Serverless Functions.
Webhook Handling: Signature Verification
Webhook endpoints must verify the origin of requests to prevent forgeries:
// 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!)
export async function POST(request: NextRequest) {
const body = await request.text() // Must read raw text โ do NOT JSON.parse
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,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
// Process asynchronously and return 200 immediately to prevent Stripe retries
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object)
break
}
return NextResponse.json({ received: true })
}
The critical point: reading the Stripe request body must use request.text(), not request.json(). Stripe's signature is computed over raw bytes โ JSON.parse followed by JSON.stringify can alter whitespace and cause signature verification to fail.
File Downloads
Route Handlers are well-suited for generating dynamic file content:
// app/api/export/csv/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getUserData } from '@/lib/db'
export async function GET(request: NextRequest) {
const userId = request.headers.get('x-user-id')
const data = await getUserData(userId!)
const rows = [
['ID', 'Name', 'Email', 'Created At'],
...data.map(row => [row.id, row.name, row.email, row.createdAt.toISOString()]),
]
const csv = rows.map(row => row.join(',')).join('\n')
return new Response(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="export.csv"',
},
})
}
Summary
Route Handlers are the external interface of Next.js's full-stack capabilities: they handle HTTP requests from the outside world, whether from mobile clients, third-party webhooks, or direct browser requests. Their design on Web standard APIs makes the code more testable and portable. Zod validation, proper CORS configuration, and rate limiting are the three pillars of production-grade Route Handlers. When deciding between Route Handlers and Server Actions, ask who the caller is: the external world calls Route Handlers, internal React components use Server Actions.