Next.js Route Handlers — Building APIs with the App Router
Advertisement
Introduction
Route Handlers are the App Router replacement for Pages Router API routes. Created by adding a route.ts file in any app directory folder, they support all HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) and run in either the Node.js or Edge Runtime. They are the right choice for webhooks, REST endpoints, and any API that cannot use Server Actions.
Why This Matters
Server Actions handle the majority of form mutations in Next.js apps, but Route Handlers are still essential for: external webhooks (Stripe, GitHub, Clerk), REST APIs consumed by mobile apps, endpoints that need specific HTTP status codes or headers, and file upload/download endpoints.
Route Handlers co-locate with your pages — app/api/posts/route.ts serves /api/posts. They share the same Edge/Node.js runtime and have full access to NextRequest and NextResponse APIs, making them more capable than Pages Router API routes.
Basic Route Handler
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') ?? '1')
const limit = parseInt(searchParams.get('limit') ?? '10')
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, slug: true, excerpt: true, createdAt: true },
})
const total = await db.post.count()
return NextResponse.json({
posts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
})
}
export async function POST(request: NextRequest) {
const body = await request.json()
if (!body.title || !body.content) {
return NextResponse.json({ error: 'Title and content are required' }, { status: 400 })
}
const post = await db.post.create({
data: { title: body.title, content: body.content, slug: slugify(body.title) },
})
return NextResponse.json(post, { status: 201 })
}Dynamic Route Handlers
Handle routes with URL parameters:
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 })
}
return NextResponse.json(post)
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const updated = await db.post.update({
where: { id },
data: { title: body.title, content: body.content, updatedAt: new Date() },
})
return NextResponse.json(updated)
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
await db.post.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}Authentication Middleware Pattern
Validate authentication inside route handlers:
// lib/api-auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
export async function withAuth(
request: NextRequest,
handler: (req: NextRequest, session: Session) => Promise<NextResponse>
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return handler(request, session)
}// app/api/user/profile/route.ts
import { withAuth } from '@/lib/api-auth'
export function GET(request: NextRequest) {
return withAuth(request, async (req, session) => {
const user = await db.user.findUnique({ where: { id: session.user.id } })
return NextResponse.json(user)
})
}Webhook Handler with Signature Verification
// 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()
const signature = request.headers.get('stripe-signature')!
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 })
}
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent
await db.order.update({
where: { stripePaymentIntentId: paymentIntent.id },
data: { status: 'paid' },
})
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: { plan: 'free' },
})
break
}
}
return NextResponse.json({ received: true })
}Streaming Responses
Stream data with ReadableStream:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const messages = ['Hello', ' from', ' streaming', ' API!']
for (const msg of messages) {
controller.enqueue(encoder.encode(`data: ${msg}\n\n`))
await new Promise((r) => setTimeout(r, 500))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}File Upload Handler
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 413 })
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
const filepath = path.join(process.cwd(), 'public', 'uploads', filename)
await writeFile(filepath, buffer)
return NextResponse.json({ url: `/uploads/${filename}` })
}Setting Cache Headers
Control caching for GET endpoints:
// app/api/config/route.ts
export async function GET() {
const config = await db.config.findFirst()
return NextResponse.json(config, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
// Or use Next.js built-in caching
export const revalidate = 3600 // Cache GET responses for 1 hourCORS Headers
Add CORS headers for cross-origin access:
// app/api/public/route.ts
import { NextRequest, NextResponse } from 'next/server'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders })
}
export async function GET(request: NextRequest) {
const data = await fetchPublicData()
return NextResponse.json(data, { headers: corsHeaders })
}Common Mistakes
- Using Route Handlers for form submissions when Server Actions are simpler and more appropriate
- Not handling the
OPTIONSpreflight request when serving to external clients - Reading
request.bodydirectly — userequest.json(),request.text(), orrequest.formData() - Forgetting to await
paramsin Next.js 15 — route params are Promises in the App Router - Not verifying webhook signatures — exposes your endpoints to spoofed events
Best Practices
- Use Server Actions for internal form submissions; use Route Handlers for external APIs and webhooks
- Always validate and sanitize request bodies before processing
- Return appropriate HTTP status codes (201 for created, 204 for deleted, 400 for bad input, 401 for unauthorized)
- Implement request signature verification for all webhook endpoints
- Add rate limiting at the middleware level rather than inside individual route handlers
Key Takeaways
- Route Handlers replace Pages Router API routes — created with
route.tsin anyappfolder - Export named functions for each HTTP method:
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS - Dynamic route params in Next.js 15 are Promises — await
paramsbefore accessing properties - Route Handlers run in Node.js by default; add
export const runtime = 'edge'for Edge Runtime export const revalidate = Ncaches GET responses for N seconds — useful for public data APIs- Webhook handlers must use
request.text()to read the raw body before signature verification ReadableStreamenables server-sent events (SSE) and streaming responses from Route Handlers- CORS requires an
OPTIONShandler for preflight requests in addition to CORS headers on the main response
Advertisement