NextJS DOC
Part 14 of 15Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
Route Handlers in Next.js App Router provide a modern, standards-based approach to building API endpoints. Unlike the Pages Router's API Routes, Route Handlers embrace the Web Request/Response APIs directly, giving you full control over HTTP semantics while maintaining Next.js's optimization capabilities.
Route Handler Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Incoming Request │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Middleware Layer │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ NextRequest (Extended Web Request) │ │
│ │ • cookies: RequestCookies │ │
│ │ • nextUrl: NextURL (pathname, searchParams, basePath) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Route Resolution │
│ app/ │
│ ├── api/ │
│ │ ├── route.ts → /api │
│ │ ├── users/ │
│ │ │ ├── route.ts → /api/users │
│ │ │ └── [id]/ │
│ │ │ └── route.ts → /api/users/:id │
│ │ └── [...slug]/ │
│ │ └── route.ts → /api/* │
│ └── feed.xml/ │
│ └── route.ts → /feed.xml │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler Execution │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ export async function GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS│ │
│ │ (request: NextRequest, context: RouteContext) │ │
│ │ │ │
│ │ Context: │ │
│ │ params: Promise<{ [key]: string }> │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Response Generation │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Web Response / NextResponse │ │
│ │ • Response.json() │ │
│ │ • NextResponse.json({ data }, { status, headers }) │ │
│ │ • NextResponse.redirect(url) │ │
│ │ • NextResponse.rewrite(url) │ │
│ │ • new Response(body, { status, headers }) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
File Convention and HTTP Methods
Route Handlers are defined in route.ts (or route.js) files. Each file can export functions named after HTTP methods:
// app/api/users/route.ts
// Supported HTTP methods - each receives NextRequest
export async function GET(request: Request) {
return Response.json({ users: [] })
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json({ created: body }, { status: 201 })
}
export async function PUT(request: Request) {
const body = await request.json()
return Response.json({ updated: body })
}
export async function PATCH(request: Request) {
const body = await request.json()
return Response.json({ patched: body })
}
export async function DELETE(request: Request) {
return new Response(null, { status: 204 })
}
export async function HEAD(request: Request) {
return new Response(null, {
headers: { 'X-Total-Count': '100' }
})
}
// OPTIONS is auto-implemented if not defined
// Next.js sets Allow header based on exported methods
export async function OPTIONS(request: Request) {
return new Response(null, {
headers: {
'Allow': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS'
}
})
}
Route Handler vs page.tsx Conflict
A route.ts file cannot coexist with a page.tsx in the same directory:
app/
├── api/
│ └── route.ts ✓ Valid - no page.tsx conflict
├── users/
│ ├── page.tsx ✗ Conflict - can't have both
│ └── route.ts ✗ Conflict - can't have both
└── dashboard/
├── page.tsx ✓ Valid - page for /dashboard
└── api/
└── route.ts ✓ Valid - API at /dashboard/api
NextRequest: Extended Web Request
NextRequest extends the standard Web Request API with Next.js-specific conveniences:
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
// ═══════════════════════════════════════════════════
// nextUrl - Extended URL with Next.js properties
// ═══════════════════════════════════════════════════
const { nextUrl } = request
// Pathname without query string
console.log(nextUrl.pathname) // '/api/users'
// Query parameters as URLSearchParams
const searchParams = nextUrl.searchParams
const page = searchParams.get('page') // '1'
const limit = searchParams.get('limit') // '10'
const tags = searchParams.getAll('tag') // ['react', 'next']
// Base path if configured in next.config.js
console.log(nextUrl.basePath) // '/docs' or ''
// Build ID for cache invalidation
console.log(nextUrl.buildId) // 'abc123' or undefined
// ═══════════════════════════════════════════════════
// cookies - Request cookie access
// ═══════════════════════════════════════════════════
const token = request.cookies.get('token')?.value
const allCookies = request.cookies.getAll()
const hasSession = request.cookies.has('session')
// ═══════════════════════════════════════════════════
// Standard Web Request properties
// ═══════════════════════════════════════════════════
console.log(request.method) // 'GET'
console.log(request.url) // Full URL string
console.log(request.headers) // Headers object
// Note: ip and geo were removed in Next.js 15
// Use headers or third-party services for geolocation
return Response.json({ pathname: nextUrl.pathname })
}
URL Query Parameter Patterns
// app/api/search/route.ts
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
// Single value
const query = searchParams.get('q')
// Multiple values (same key)
// /api/search?category=electronics&category=books
const categories = searchParams.getAll('category')
// Check existence
if (searchParams.has('sort')) {
const sort = searchParams.get('sort')
}
// Iterate all params
const params: Record<string, string[]> = {}
searchParams.forEach((value, key) => {
if (!params[key]) params[key] = []
params[key].push(value)
})
// Convert to object (single values only)
const paramsObject = Object.fromEntries(searchParams)
return Response.json({ query, categories, params })
}
NextResponse: Extended Web Response
NextResponse provides static convenience methods for common response patterns:
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
// ═══════════════════════════════════════════════════
// JSON responses
// ═══════════════════════════════════════════════════
// Simple JSON response
return NextResponse.json({ data: 'value' })
// With status and headers
return NextResponse.json(
{ error: 'Not found' },
{
status: 404,
headers: { 'X-Error-Code': 'RESOURCE_NOT_FOUND' }
}
)
// ═══════════════════════════════════════════════════
// Redirects
// ═══════════════════════════════════════════════════
// Redirect to absolute URL
return NextResponse.redirect(new URL('/login', request.url))
// Redirect with modified URL
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
// Permanent redirect (308)
return NextResponse.redirect(
new URL('/new-path', request.url),
{ status: 308 }
)
// ═══════════════════════════════════════════════════
// Rewrites (internal proxying)
// ═══════════════════════════════════════════════════
// Browser shows /about, server handles /proxy
return NextResponse.rewrite(new URL('/proxy', request.url))
// Proxy to external URL
return NextResponse.rewrite(
new URL('https://api.external.com/data')
)
// ═══════════════════════════════════════════════════
// next() - Continue routing (primarily for middleware)
// ═══════════════════════════════════════════════════
return NextResponse.next()
// Forward modified headers upstream
const newHeaders = new Headers(request.headers)
newHeaders.set('x-user-id', 'user_123')
return NextResponse.next({
request: { headers: newHeaders }
})
}
Response Cookie Management
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const response = NextResponse.json({ success: true })
// Set a cookie
response.cookies.set('token', 'abc123', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
})
// Set multiple cookies
response.cookies.set({
name: 'session',
value: 'session_id',
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: new Date(Date.now() + 86400000),
})
// Delete a cookie
response.cookies.delete('old_token')
// Read cookies from response
const token = response.cookies.get('token')
const allCookies = response.cookies.getAll()
return response
}
Dynamic Route Parameters
Dynamic segments in the route path are passed via the context parameter:
// app/api/users/[id]/route.ts
import { type NextRequest } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// params is a Promise in Next.js 15+
const { id } = await params
return Response.json({ userId: id })
}
// Multiple dynamic segments
// app/api/orgs/[orgId]/teams/[teamId]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ orgId: string; teamId: string }> }
) {
const { orgId, teamId } = await params
return Response.json({ orgId, teamId })
}
// Catch-all segments
// app/api/files/[...path]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
// /api/files/docs/images/logo.png → ['docs', 'images', 'logo.png']
return Response.json({ path: path.join('/') })
}
// Optional catch-all
// app/api/[[...slug]]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
const { slug } = await params
// /api → { slug: undefined }
// /api/a/b → { slug: ['a', 'b'] }
return Response.json({ slug: slug || [] })
}
RouteContext Type Helper
Next.js provides a global RouteContext helper for strongly-typed route parameters:
// app/api/users/[id]/posts/[postId]/route.ts
import { type NextRequest } from 'next/server'
// RouteContext is globally available after type generation
// Run: next dev, next build, or next typegen
export async function GET(
request: NextRequest,
ctx: RouteContext<'/api/users/[id]/posts/[postId]'>
) {
// ctx.params is Promise<{ id: string; postId: string }>
const { id, postId } = await ctx.params
return Response.json({ userId: id, postId })
}
Request Body Handling
JSON Bodies
// app/api/users/route.ts
import { type NextRequest } from 'next/server'
interface CreateUserBody {
name: string
email: string
role?: 'admin' | 'user'
}
export async function POST(request: NextRequest) {
try {
const body: CreateUserBody = await request.json()
// Validate required fields
if (!body.name || !body.email) {
return Response.json(
{ error: 'Name and email are required' },
{ status: 400 }
)
}
// Process the request...
const user = await createUser(body)
return Response.json(user, { status: 201 })
} catch (error) {
// Handle JSON parse errors
if (error instanceof SyntaxError) {
return Response.json(
{ error: 'Invalid JSON body' },
{ status: 400 }
)
}
throw error
}
}
FormData Bodies
// app/api/upload/route.ts
import { type NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function POST(request: NextRequest) {
const formData = await request.formData()
// Text fields
const name = formData.get('name') as string
const email = formData.get('email') as string
// File upload
const file = formData.get('avatar') as File | null
if (!file) {
return Response.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
}
// Validate file type
if (!file.type.startsWith('image/')) {
return Response.json(
{ error: 'Only images allowed' },
{ status: 400 }
)
}
// Read file as buffer
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Save to filesystem
const uploadDir = join(process.cwd(), 'public', 'uploads')
const filename = `${Date.now()}-${file.name}`
await writeFile(join(uploadDir, filename), buffer)
return Response.json({
name,
email,
avatarUrl: `/uploads/${filename}`
})
}
// Multiple files
export async function POST_MULTI(request: NextRequest) {
const formData = await request.formData()
// Get all files with same field name
const files = formData.getAll('files') as File[]
const uploadedFiles = await Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(
join(process.cwd(), 'public', 'uploads', filename),
buffer
)
return { name: file.name, url: `/uploads/${filename}` }
})
)
return Response.json({ files: uploadedFiles })
}
Zod Validation with FormData
// app/api/contact/route.ts
import { type NextRequest } from 'next/server'
import { z } from 'zod'
import { zfd } from 'zod-form-data'
// Define schema using zod-form-data
const contactSchema = zfd.formData({
name: zfd.text(z.string().min(2).max(100)),
email: zfd.text(z.string().email()),
message: zfd.text(z.string().min(10).max(1000)),
priority: zfd.numeric(z.number().int().min(1).max(5).optional()),
attachment: zfd.file(z.instanceof(File).optional()),
})
export async function POST(request: NextRequest) {
const formData = await request.formData()
const result = contactSchema.safeParse(formData)
if (!result.success) {
return Response.json(
{
error: 'Validation failed',
details: result.error.flatten()
},
{ status: 400 }
)
}
const { name, email, message, priority, attachment } = result.data
// Process validated data...
return Response.json({ success: true })
}
Raw Body (for Webhooks)
// app/api/webhooks/stripe/route.ts
import { type NextRequest } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: NextRequest) {
// Get raw body text for signature verification
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return Response.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 }
)
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session
await handleCheckoutComplete(session)
break
case 'invoice.paid':
const invoice = event.data.object as Stripe.Invoice
await handleInvoicePaid(invoice)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return Response.json({ received: true })
}
Headers and Cookies
Reading Headers
// app/api/route.ts
import { headers, cookies } from 'next/headers'
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
// Method 1: Using next/headers (recommended)
const headersList = await headers()
const userAgent = headersList.get('user-agent')
const authorization = headersList.get('authorization')
const contentType = headersList.get('content-type')
// Method 2: From NextRequest directly
const acceptLanguage = request.headers.get('accept-language')
// Create new Headers from request
const requestHeaders = new Headers(request.headers)
// Iterate all headers
const allHeaders: Record<string, string> = {}
headersList.forEach((value, key) => {
allHeaders[key] = value
})
return Response.json({ headers: allHeaders })
}
Reading Cookies
// app/api/auth/me/route.ts
import { cookies } from 'next/headers'
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
// Method 1: Using next/headers (recommended)
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
const allCookies = cookieStore.getAll()
const hasToken = cookieStore.has('token')
// Method 2: From NextRequest directly
const token = request.cookies.get('token')
const tokenValue = token?.value
if (!sessionToken) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
return Response.json({
authenticated: true,
session: sessionToken
})
}
Setting Response Headers
// app/api/data/route.ts
export async function GET() {
const data = await fetchData()
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60, s-maxage=300',
'X-Response-Time': '42ms',
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '99',
}
})
}
Setting Cookies in Response
// app/api/auth/login/route.ts
import { cookies } from 'next/headers'
export async function POST(request: Request) {
const { email, password } = await request.json()
// Authenticate user...
const session = await authenticate(email, password)
if (!session) {
return Response.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
// Set cookie using next/headers
const cookieStore = await cookies()
cookieStore.set('session', session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
})
return Response.json({
user: session.user,
expiresAt: session.expiresAt
})
}
// Delete cookie on logout
export async function DELETE() {
const cookieStore = await cookies()
cookieStore.delete('session')
return Response.json({ success: true })
}
Streaming Responses
Basic Streaming with ReadableStream
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 1; i <= 10; i++) {
controller.enqueue(encoder.encode(`Chunk ${i}\n`))
await new Promise(resolve => setTimeout(resolve, 500))
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
}
})
}
Server-Sent Events (SSE)
// app/api/events/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Send initial connection event
controller.enqueue(
encoder.encode('event: connected\ndata: {}\n\n')
)
// Simulate real-time updates
let count = 0
const interval = setInterval(() => {
count++
const data = JSON.stringify({
count,
timestamp: Date.now()
})
controller.enqueue(
encoder.encode(`event: update\ndata: ${data}\n\n`)
)
if (count >= 10) {
clearInterval(interval)
controller.enqueue(
encoder.encode('event: complete\ndata: {}\n\n')
)
controller.close()
}
}, 1000)
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
}
})
}
AI/LLM Streaming with Vercel AI SDK
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
export async function POST(request: Request) {
const { messages } = await request.json()
const result = await streamText({
model: openai('gpt-4-turbo'),
messages,
system: 'You are a helpful assistant.',
})
return result.toDataStreamResponse()
}
Streaming with Async Generator
// app/api/process/route.ts
async function* processItems(items: string[]) {
for (const item of items) {
// Simulate processing
await new Promise(resolve => setTimeout(resolve, 200))
yield { item, processed: true, timestamp: Date.now() }
}
}
function iteratorToStream(iterator: AsyncIterableIterator<unknown>) {
const encoder = new TextEncoder()
return new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next()
if (done) {
controller.close()
} else {
const json = JSON.stringify(value) + '\n'
controller.enqueue(encoder.encode(json))
}
}
})
}
export async function POST(request: Request) {
const { items } = await request.json()
const iterator = processItems(items)
const stream = iteratorToStream(iterator)
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
}
})
}
Caching and Revalidation
Static vs Dynamic Behavior
Route Handlers have different caching behavior depending on the method and context:
┌─────────────────────────────────────────────────────────────────────┐
│ Caching Decision Flow │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ HTTP Method? │
└─────────────────────┘
│ │
GET/HEAD Other
│ │
▼ ▼
┌───────────────────┐ ┌─────────────────┐
│ Check for dynamic │ │ Always Dynamic │
│ functions │ │ (no cache) │
└───────────────────┘ └─────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Dynamic Functions Used? │
│ • cookies() │
│ • headers() │
│ • request object used │
│ • Dynamic route params │
│ • Request body read │
│ • dynamic = 'force-dynamic' │
└──────────────────────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Dynamic Route │ │ Static Route │
│ (render on each │ │ (cached at build │
│ request) │ │ time) │
└───────────────────┘ └───────────────────┘
Route Segment Config Options
// app/api/posts/route.ts
// Force static generation
export const dynamic = 'force-static'
// Force dynamic rendering
export const dynamic = 'force-dynamic'
// Revalidation period (in seconds)
export const revalidate = 60
// Runtime (nodejs or edge)
export const runtime = 'nodejs'
// Preferred deployment regions
export const preferredRegion = ['iad1', 'sfo1']
// Maximum execution duration
export const maxDuration = 30
export async function GET() {
const posts = await fetchPosts()
return Response.json(posts)
}
Time-Based Revalidation
// app/api/posts/route.ts
// Revalidate every 60 seconds
export const revalidate = 60
export async function GET() {
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
const posts = await data.json()
return Response.json(posts)
}
On-Demand Revalidation
// app/api/posts/route.ts
export async function GET() {
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
const posts = await data.json()
return Response.json(posts)
}
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'
export async function POST(request: Request) {
const { tag, path, secret } = await request.json()
// Validate secret
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json(
{ error: 'Invalid secret' },
{ status: 401 }
)
}
if (tag) {
revalidateTag(tag)
return Response.json({ revalidated: true, tag })
}
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, path })
}
return Response.json(
{ error: 'Tag or path required' },
{ status: 400 }
)
}
Static Generation with generateStaticParams
// app/api/posts/[slug]/route.ts
// Pre-render these routes at build time
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
const post = await fetchPost(slug)
if (!post) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return Response.json(post)
}
CORS Configuration
Per-Route CORS
// app/api/route.ts
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400', // 24 hours
}
// Handle preflight requests
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: corsHeaders
})
}
export async function GET() {
return Response.json(
{ message: 'Hello' },
{ headers: corsHeaders }
)
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json(
{ received: body },
{ headers: corsHeaders }
)
}
CORS Middleware Pattern
// lib/cors.ts
type CorsOptions = {
origin: string | string[] | ((origin: string) => boolean)
methods?: string[]
allowedHeaders?: string[]
exposedHeaders?: string[]
credentials?: boolean
maxAge?: number
}
export function cors(handler: Function, options: CorsOptions) {
return async function(request: Request) {
const origin = request.headers.get('origin') || ''
// Check if origin is allowed
let isAllowed = false
if (typeof options.origin === 'string') {
isAllowed = options.origin === '*' || options.origin === origin
} else if (Array.isArray(options.origin)) {
isAllowed = options.origin.includes(origin)
} else if (typeof options.origin === 'function') {
isAllowed = options.origin(origin)
}
const corsHeaders: Record<string, string> = {}
if (isAllowed) {
corsHeaders['Access-Control-Allow-Origin'] =
options.origin === '*' ? '*' : origin
if (options.credentials) {
corsHeaders['Access-Control-Allow-Credentials'] = 'true'
}
}
corsHeaders['Access-Control-Allow-Methods'] =
(options.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']).join(', ')
corsHeaders['Access-Control-Allow-Headers'] =
(options.allowedHeaders || ['Content-Type', 'Authorization']).join(', ')
if (options.exposedHeaders) {
corsHeaders['Access-Control-Expose-Headers'] =
options.exposedHeaders.join(', ')
}
if (options.maxAge) {
corsHeaders['Access-Control-Max-Age'] = String(options.maxAge)
}
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders
})
}
// Call the handler
const response = await handler(request)
// Add CORS headers to response
const newHeaders = new Headers(response.headers)
Object.entries(corsHeaders).forEach(([key, value]) => {
newHeaders.set(key, value)
})
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
})
}
}
// Usage in route.ts
import { cors } from '@/lib/cors'
async function handler(request: Request) {
return Response.json({ data: 'value' })
}
export const GET = cors(handler, {
origin: ['https://app.example.com', 'https://admin.example.com'],
credentials: true,
maxAge: 86400,
})
export const OPTIONS = cors(async () => new Response(null), {
origin: '*',
})
Global CORS via next.config.js
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
]
},
}
Redirects
// app/api/redirect/route.ts
import { redirect, permanentRedirect } from 'next/navigation'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const url = new URL(request.url)
const destination = url.searchParams.get('to')
// Method 1: Using next/navigation redirect (throws, 307)
redirect('/new-location')
// Method 2: Permanent redirect (throws, 308)
permanentRedirect('/new-permanent-location')
// Method 3: NextResponse.redirect (returns Response)
return NextResponse.redirect(new URL('/target', request.url))
// Method 4: With custom status
return NextResponse.redirect(
new URL('/moved', request.url),
{ status: 301 } // Moved Permanently
)
// Method 5: External redirect
return NextResponse.redirect('https://example.com')
}
Non-UI Responses
XML/RSS Feed
// app/feed.xml/route.ts
export async function GET() {
const posts = await fetchRecentPosts()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>https://example.com</link>
<description>Latest posts from my blog</description>
<atom:link href="https://example.com/feed.xml" rel="self" type="application/rss+xml"/>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${posts.map(post => `
<item>
<title><![CDATA[${post.title}]]></title>
<link>https://example.com/posts/${post.slug}</link>
<guid isPermaLink="true">https://example.com/posts/${post.slug}</guid>
<pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
<description><![CDATA[${post.excerpt}]]></description>
</item>`).join('')}
</channel>
</rss>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
}
})
}
Sitemap
// app/sitemap.xml/route.ts
export async function GET() {
const pages = await fetchAllPages()
const posts = await fetchAllPosts()
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
${pages.map(page => `
<url>
<loc>https://example.com${page.path}</loc>
<lastmod>${page.updatedAt}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`).join('')}
${posts.map(post => `
<url>
<loc>https://example.com/posts/${post.slug}</loc>
<lastmod>${post.updatedAt}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>`).join('')}
</urlset>`
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
}
})
}
Binary Files
// app/api/export/route.ts
import { createWriteStream } from 'fs'
import archiver from 'archiver'
export async function GET() {
// Generate PDF
const pdfBuffer = await generatePDF()
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
}
})
}
// CSV export
export async function GET_CSV() {
const data = await fetchData()
const csv = [
['Name', 'Email', 'Created At'].join(','),
...data.map(row =>
[row.name, row.email, row.createdAt].join(',')
)
].join('\n')
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="export.csv"',
}
})
}
Production Patterns
API Response Wrapper
// lib/api-response.ts
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
type ApiResponse<T> = {
success: true
data: T
meta?: Record<string, unknown>
} | {
success: false
error: {
code: string
message: string
details?: unknown
}
}
export function success<T>(
data: T,
meta?: Record<string, unknown>,
status = 200
): NextResponse<ApiResponse<T>> {
return NextResponse.json(
{ success: true, data, meta },
{ status }
)
}
export function error(
code: string,
message: string,
status = 400,
details?: unknown
): NextResponse<ApiResponse<never>> {
return NextResponse.json(
{ success: false, error: { code, message, details } },
{ status }
)
}
export function handleApiError(err: unknown): NextResponse<ApiResponse<never>> {
console.error('API Error:', err)
if (err instanceof ZodError) {
return error(
'VALIDATION_ERROR',
'Invalid request data',
400,
err.flatten()
)
}
if (err instanceof NotFoundError) {
return error('NOT_FOUND', err.message, 404)
}
if (err instanceof UnauthorizedError) {
return error('UNAUTHORIZED', err.message, 401)
}
if (err instanceof ForbiddenError) {
return error('FORBIDDEN', err.message, 403)
}
return error(
'INTERNAL_ERROR',
'An unexpected error occurred',
500
)
}
// Custom error classes
export class NotFoundError extends Error {
constructor(message = 'Resource not found') {
super(message)
this.name = 'NotFoundError'
}
}
export class UnauthorizedError extends Error {
constructor(message = 'Authentication required') {
super(message)
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor(message = 'Access denied') {
super(message)
this.name = 'ForbiddenError'
}
}
// Usage in route handler
import { success, handleApiError, NotFoundError } from '@/lib/api-response'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const user = await db.user.findUnique({ where: { id } })
if (!user) {
throw new NotFoundError(`User ${id} not found`)
}
return success(user)
} catch (err) {
return handleApiError(err)
}
}
Authentication Wrapper
// lib/auth.ts
import { cookies } from 'next/headers'
import { type NextRequest } from 'next/server'
import { verifyToken } from './jwt'
import { error } from './api-response'
type AuthContext = {
user: { id: string; email: string; role: string }
}
type AuthenticatedHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>> },
auth: AuthContext
) => Promise<Response>
export function withAuth(handler: AuthenticatedHandler) {
return async function(
request: NextRequest,
context: { params: Promise<Record<string, string>> }
): Promise<Response> {
const cookieStore = await cookies()
const token = cookieStore.get('session')?.value
if (!token) {
return error('UNAUTHORIZED', 'Authentication required', 401)
}
try {
const payload = await verifyToken(token)
const auth: AuthContext = {
user: {
id: payload.sub!,
email: payload.email as string,
role: payload.role as string,
}
}
return handler(request, context, auth)
} catch (err) {
return error('UNAUTHORIZED', 'Invalid or expired token', 401)
}
}
}
// Role-based authorization
export function withRole(roles: string[], handler: AuthenticatedHandler) {
return withAuth(async (request, context, auth) => {
if (!roles.includes(auth.user.role)) {
return error(
'FORBIDDEN',
`Role ${auth.user.role} cannot access this resource`,
403
)
}
return handler(request, context, auth)
})
}
// Usage
// app/api/admin/users/route.ts
import { withRole } from '@/lib/auth'
import { success } from '@/lib/api-response'
export const GET = withRole(['admin'], async (request, context, auth) => {
const users = await db.user.findMany()
return success(users)
})
Rate Limiting
// lib/rate-limit.ts
import { type NextRequest } from 'next/server'
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
import { error } from './api-response'
const redis = Redis.fromEnv()
// Create rate limiters for different tiers
const rateLimiters = {
anonymous: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
analytics: true,
}),
authenticated: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '10 s'),
analytics: true,
}),
premium: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1000, '10 s'),
analytics: true,
}),
}
type RateLimitTier = keyof typeof rateLimiters
export function withRateLimit(
tier: RateLimitTier,
handler: (request: NextRequest) => Promise<Response>
) {
return async function(request: NextRequest): Promise<Response> {
const identifier =
request.headers.get('x-user-id') ||
request.ip ||
'anonymous'
const { success, limit, remaining, reset } =
await rateLimiters[tier].limit(identifier)
const headers = {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
}
if (!success) {
return new Response(
JSON.stringify({
success: false,
error: {
code: 'RATE_LIMITED',
message: 'Too many requests',
}
}),
{
status: 429,
headers: {
...headers,
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
}
}
)
}
const response = await handler(request)
// Add rate limit headers to response
const newHeaders = new Headers(response.headers)
Object.entries(headers).forEach(([key, value]) => {
newHeaders.set(key, value)
})
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
})
}
}
Request Validation
// lib/validate.ts
import { type NextRequest } from 'next/server'
import { z, ZodSchema } from 'zod'
import { error } from './api-response'
type ValidatedHandler<T> = (
request: NextRequest,
context: { params: Promise<Record<string, string>> },
body: T
) => Promise<Response>
export function withValidation<T>(
schema: ZodSchema<T>,
handler: ValidatedHandler<T>
) {
return async function(
request: NextRequest,
context: { params: Promise<Record<string, string>> }
): Promise<Response> {
try {
const body = await request.json()
const validated = schema.parse(body)
return handler(request, context, validated)
} catch (err) {
if (err instanceof z.ZodError) {
return error(
'VALIDATION_ERROR',
'Invalid request body',
400,
err.flatten()
)
}
if (err instanceof SyntaxError) {
return error('INVALID_JSON', 'Invalid JSON body', 400)
}
throw err
}
}
}
// Usage
// app/api/users/route.ts
import { withValidation } from '@/lib/validate'
import { success } from '@/lib/api-response'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['user', 'admin']).default('user'),
})
export const POST = withValidation(
createUserSchema,
async (request, context, body) => {
const user = await db.user.create({
data: {
...body,
password: await hashPassword(body.password),
},
})
return success(user, undefined, 201)
}
)
Composable Middleware Chain
// lib/middleware-chain.ts
import { type NextRequest } from 'next/server'
type Handler = (request: NextRequest, context: any) => Promise<Response>
type Middleware = (handler: Handler) => Handler
export function compose(...middlewares: Middleware[]): Middleware {
return (handler: Handler) => {
return middlewares.reduceRight(
(acc, middleware) => middleware(acc),
handler
)
}
}
// Usage
// app/api/admin/data/route.ts
import { compose } from '@/lib/middleware-chain'
import { withAuth } from '@/lib/auth'
import { withRateLimit } from '@/lib/rate-limit'
import { withValidation } from '@/lib/validate'
const middleware = compose(
withRateLimit('authenticated'),
withAuth,
)
async function handler(request: NextRequest) {
// Handler implementation
return Response.json({ data: 'value' })
}
export const GET = middleware(handler)
Edge Runtime
// app/api/edge/route.ts
// Run on Edge Runtime (V8 isolates)
export const runtime = 'edge'
// Optionally specify preferred regions
export const preferredRegion = ['iad1', 'sfo1', 'fra1']
export async function GET(request: Request) {
// Edge runtime limitations:
// - No Node.js APIs (fs, path, child_process, etc.)
// - No native modules
// - Limited to Web APIs + specific polyfills
// Available APIs:
// - fetch
// - Web Crypto API
// - TextEncoder/TextDecoder
// - URL, URLSearchParams
// - Headers, Request, Response
// - ReadableStream, WritableStream
const response = await fetch('https://api.example.com/data')
const data = await response.json()
return Response.json(data)
}
Common Pitfalls
1. Forgetting to Await params
// ❌ Wrong - params is a Promise in Next.js 15+
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id // undefined or Promise object
}
// ✓ Correct
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
}
2. Using Cookies/Headers Without Await
// ❌ Wrong - cookies() returns a Promise
export async function GET() {
const cookieStore = cookies()
const token = cookieStore.get('token') // Error
}
// ✓ Correct
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get('token')
}
3. Accidentally Making Static Routes Dynamic
// This route will be dynamic because it reads cookies
export async function GET() {
const cookieStore = await cookies()
// Even if you don't use the cookie, reading it makes the route dynamic
return Response.json({ static: false })
}
// Keep it static by not reading dynamic sources
export async function GET() {
const data = await fetch('https://api.example.com/static', {
next: { revalidate: 3600 }
})
return Response.json(await data.json())
}
4. Incorrect Response Content-Type
// ❌ Wrong - JSON body but no Content-Type
export async function GET() {
return new Response('{"error": "not found"}', { status: 404 })
// Content-Type: text/plain by default
}
// ✓ Correct
export async function GET() {
return Response.json({ error: 'not found' }, { status: 404 })
// or
return new Response(JSON.stringify({ error: 'not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
5. Blocking Operations in Edge Runtime
// ❌ Wrong - fs not available in Edge
export const runtime = 'edge'
export async function GET() {
const fs = require('fs')
const data = fs.readFileSync('./data.json') // Error
}
// ✓ Correct - use fetch or external APIs
export const runtime = 'edge'
export async function GET() {
const response = await fetch('https://storage.example.com/data.json')
const data = await response.json()
return Response.json(data)
}
Key Takeaways
-
Web Standards First: Route Handlers use standard Web Request/Response APIs, making them portable and familiar to developers from other frameworks.
-
Params are Promises: In Next.js 15+, dynamic route parameters are accessed via
await params, not directly. -
Caching by Default Changed: Next.js 15 changed GET handlers from static to dynamic by default. Use
export const dynamic = 'force-static'or avoid dynamic functions to enable caching. -
Use NextRequest/NextResponse: These extend Web APIs with Next.js conveniences like
nextUrl.searchParamsandcookies.set(). -
Edge vs Node.js Runtime: Choose Edge for low-latency, globally distributed handlers; Node.js for handlers needing native modules or filesystem access.
-
Compose Middleware: Build reusable middleware for auth, validation, rate limiting using higher-order functions.
-
Streaming for Long Operations: Use ReadableStream for SSE, AI responses, or large data exports.
-
Type Safety with RouteContext: Use the global
RouteContext<'/path/[param]'>helper for strongly-typed route parameters.
What did you think?