Introduction
The App Router introduced a fundamentally different approach to building APIs in Next.js. Route Handlers replaced the Pages Router's API routes with a model built on Web standards, offering better type safety, colocated file organization, and native support for streaming and edge deployment. For developers building full-stack Next.js applications, understanding Route Handlers is essential for creating backend logic that integrates seamlessly with Server Components and Server Actions.
Unlike Pages Router API routes that received a modified NextApiRequest and NextApiResponse, Route Handlers work with the standard Request and Response objects from the Web Fetch API. This means your API layer is portable across runtimes, testable with standard HTTP clients, and compatible with the growing ecosystem of Web API tooling.
This guide covers the complete journey from basic CRUD operations to advanced patterns like streaming, file uploads, and webhook processing. We'll build a realistic API layer for a content management system that demonstrates the patterns you'll use in production applications.
Understanding Route Handlers: Core Concepts
File-Based API Routing
Route Handlers follow the same file-system conventions as pages. A route.ts file in a directory defines the API endpoint for that path:
app/
api/
posts/
route.ts → GET /api/posts, POST /api/posts
[id]/
route.ts → GET /api/posts/:id, PUT /api/posts/:id, DELETE /api/posts/:id
[id]/comments/
route.ts → GET /api/posts/:id/comments, POST /api/posts/:id/comments
users/
route.ts → GET /api/users, POST /api/users
health/
route.ts → GET /api/health
Each route.ts exports named functions for the HTTP methods it supports:
// app/api/posts/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
})
return Response.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
const post = await db.posts.create({ data: body })
return Response.json(post, { status: 201 })
}Dynamic Segments and Type Safety
Dynamic segments are available through the second parameter of handler functions:
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const { id } = await params
const post = await db.posts.findUnique({
where: { id },
include: { author: true, tags: true },
})
if (!post) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return Response.json(post)
}Catch-All Segments
For deeply nested APIs, catch-all segments capture multiple path levels:
// app/api/docs/[...slug]/route.ts
export async function GET(
request: Request,
{ params }: { params: { slug: string[] } }
) {
const { slug } = await params
const path = slug.join('/') // e.g., ['guides', 'getting-started'] → 'guides/getting-started'
const document = await findDocumentByPath(path)
if (!document) {
return Response.json({ error: 'Document not found' }, { status: 404 })
}
return Response.json(document)
}The Cookies and Headers APIs
Route Handlers can read request cookies and headers using the Web API:
// app/api/me/route.ts
export async function GET(request: Request) {
const cookieHeader = request.headers.get('cookie') || ''
const token = parseCookie(cookieHeader, 'session-token')
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const session = await verifySession(token)
const user = await db.users.findUnique({ where: { id: session.userId } })
return Response.json(user)
}For Next.js-specific cookie handling, use the cookies() function from next/headers:
import { cookies, headers } from 'next/headers'
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get('session-token')
const headerStore = await headers()
const userAgent = headerStore.get('user-agent')
return Response.json({ hasToken: !!token, userAgent })
}Architecture and Design Patterns
Separating Concerns with a Service Layer
Keep Route Handlers thin by delegating business logic to service modules:
// lib/services/post-service.ts
import { db } from '@/lib/db'
import { slugify } from '@/lib/utils'
export const postService = {
async findAll(options: { page: number; limit: number; tag?: string }) {
const where = options.tag ? { tags: { some: { slug: options.tag } } } : {}
const [posts, total] = await Promise.all([
db.posts.findMany({
where,
skip: (options.page - 1) * options.limit,
take: options.limit,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true, avatar: true } } },
}),
db.posts.count({ where }),
])
return {
posts,
pagination: {
page: options.page,
limit: options.limit,
total,
pages: Math.ceil(total / options.limit),
},
}
},
async create(data: CreatePostInput, authorId: string) {
return db.posts.create({
data: {
...data,
slug: slugify(data.title),
authorId,
},
})
},
async update(id: string, data: UpdatePostInput, userId: string) {
const post = await db.posts.findUnique({ where: { id } })
if (!post) throw new ApiError('Post not found', 404)
if (post.authorId !== userId) throw new ApiError('Forbidden', 403)
return db.posts.update({ where: { id }, data })
},
async delete(id: string, userId: string) {
const post = await db.posts.findUnique({ where: { id } })
if (!post) throw new ApiError('Post not found', 404)
if (post.authorId !== userId) throw new ApiError('Forbidden', 403)
return db.posts.delete({ where: { id } })
},
}Request Validation with Zod
Define validation schemas that generate both runtime validators and TypeScript types:
// lib/validation/post.ts
import { z } from 'zod'
export const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).max(10).default([]),
published: z.boolean().default(false),
coverImage: z.string().url().optional(),
})
export const UpdatePostSchema = CreatePostSchema.partial()
export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>
// lib/api/validate.ts
export async function validateRequest<T>(
request: Request,
schema: z.ZodSchema<T>
): Promise<{ success: true; data: T } | { success: false; error: Response }> {
try {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return {
success: false,
error: Response.json(
{
error: 'Validation failed',
details: result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
},
{ status: 400 }
),
}
}
return { success: true, data: result.data }
} catch {
return {
success: false,
error: Response.json(
{ error: 'Invalid JSON body' },
{ status: 400 }
),
}
}
}Centralized Error Handling
Create a wrapper that catches all errors and formats responses consistently:
// lib/api/handler.ts
type RouteHandler = (
request: Request,
context: { params: Record<string, string> }
) => Promise<Response>
export function createHandler(handler: RouteHandler): RouteHandler {
return async (request, context) => {
try {
return await handler(request, context)
} catch (error) {
console.error(`[API Error] ${request.method} ${new URL(request.url).pathname}:`, error)
if (error instanceof ApiError) {
return Response.json(
{ error: error.code, message: error.message },
{ status: error.statusCode }
)
}
return Response.json(
{ error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
{ status: 500 }
)
}
}
}Step-by-Step Implementation
Step 1: Set Up the Database Connection
Use a singleton Prisma client for development and production:
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = dbStep 2: Build the Posts CRUD API
Implement complete CRUD operations with validation and error handling:
// app/api/posts/route.ts
import { CreatePostSchema } from '@/lib/validation/post'
import { postService } from '@/lib/services/post-service'
import { createHandler, validateRequest } from '@/lib/api'
import { getSession } from '@/lib/auth'
export const GET = createHandler(async (request) => {
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '10')))
const tag = searchParams.get('tag') || undefined
const result = await postService.findAll({ page, limit, tag })
return Response.json(result, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
})
})
export const POST = createHandler(async (request) => {
const session = await getSession(request)
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateRequest(request, CreatePostSchema)
if (!validation.success) {
return validation.error
}
const post = await postService.create(validation.data, session.userId)
return Response.json(post, {
status: 201,
headers: {
'Location': `/api/posts/${post.id}`,
},
})
})Step 3: Implement the Single Post Endpoint
// app/api/posts/[id]/route.ts
import { UpdatePostSchema } from '@/lib/validation/post'
import { postService } from '@/lib/services/post-service'
import { createHandler, validateRequest } from '@/lib/api'
import { getSession } from '@/lib/auth'
export const GET = createHandler(async (request, { params }) => {
const { id } = await params
const post = await postService.findById(id)
if (!post) {
return Response.json({ error: 'Post not found' }, { status: 404 })
}
return Response.json(post, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
'ETag': `"${post.updatedAt.getTime()}"`,
},
})
})
export const PUT = createHandler(async (request, { params }) => {
const { id } = await params
const session = await getSession(request)
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateRequest(request, UpdatePostSchema)
if (!validation.success) {
return validation.error
}
const post = await postService.update(id, validation.data, session.userId)
return Response.json(post)
})
export const DELETE = createHandler(async (request, { params }) => {
const { id } = await params
const session = await getSession(request)
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
await postService.delete(id, session.userId)
return new Response(null, { status: 204 })
})Step 4: Add File Upload Support
Handle multipart form data for image uploads:
// app/api/upload/route.ts
import { put } from '@vercel/blob'
import { createHandler } from '@/lib/api'
import { getSession } from '@/lib/auth'
export const POST = createHandler(async (request) => {
const session = await getSession(request)
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return Response.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type and size
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!allowedTypes.includes(file.type)) {
return Response.json(
{ error: 'Invalid file type. Allowed: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return Response.json(
{ error: 'File too large. Maximum size: 5MB' },
{ status: 400 }
)
}
const blob = await put(`uploads/${session.userId}/${file.name}`, file, {
access: 'public',
})
return Response.json({
url: blob.url,
filename: file.name,
size: file.size,
type: file.type,
})
})Step 5: Implement Webhook Endpoints
Process incoming webhooks with signature verification:
// app/api/webhooks/[provider]/route.ts
import { createHandler } from '@/lib/api'
import { verifyStripeSignature, verifyGitHubSignature } from '@/lib/webhooks'
export const POST = createHandler(async (request, { params }) => {
const { provider } = await params
const body = await request.text()
const signature = request.headers.get('x-signature') || request.headers.get('stripe-signature') || ''
let verified = false
let event: any
switch (provider) {
case 'stripe':
try {
event = verifyStripeSignature(body, signature)
verified = true
} catch (e) {
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
break
case 'github':
verified = verifyGitHubSignature(body, signature)
if (!verified) {
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
event = JSON.parse(body)
break
default:
return Response.json({ error: 'Unknown provider' }, { status: 400 })
}
// Process event asynchronously
await processWebhookEvent(provider, event)
return Response.json({ received: true })
})Real-World Use Cases
Use Case 1: REST API for a Blog Platform
A content management system needs CRUD endpoints for posts, comments, users, and media. Route Handlers provide the API layer while Server Components consume it for server-rendered pages. The same API can serve a mobile app or third-party integrations.
Use Case 2: Payment Processing Webhooks
Stripe, PayPal, and other payment providers send webhooks for payment events. Route Handlers are ideal because they can verify signatures, process events idempotently, and respond quickly while queuing heavy processing for background jobs.
Use Case 3: Real-Time Data Endpoints
Server-Sent Events (SSE) via Route Handlers enable real-time features like live notifications, chat messages, or dashboard updates. The streaming response keeps the connection open and pushes data to the client as events occur.
Use Case 4: Internal Microservice Gateway
In a microservice architecture, Next.js Route Handlers can serve as a Backend-for-Frontend (BFF) layer, aggregating data from multiple internal services into a single optimized response for the client.
Best Practices for Production
-
Export named functions for each HTTP method: Don't use a single default export that checks
request.method. Named exports (GET,POST,PUT,DELETE) enable Next.js to optimize each method independently and are clearer to read. -
Validate every request body: Use Zod schemas for runtime validation. Never trust client input—even if your frontend validates, malicious clients can send anything.
-
Use proper HTTP status codes: 200 for success, 201 for created, 204 for no content (deletes), 400 for bad input, 401 for unauthenticated, 403 for unauthorized, 404 for not found, 409 for conflicts, 429 for rate limits, 500 for server errors.
-
Set Cache-Control headers explicitly: Route Handlers are not cached by default. For GET endpoints that serve public data, set appropriate cache headers to leverage CDN caching.
-
Use
Response.json()consistently: Don't constructnew Response(JSON.stringify(data)). TheResponse.json()method handles content-type headers and encoding automatically. -
Handle concurrent requests safely: In serverless environments, each request runs in its own context. Don't rely on in-memory state for cross-request data. Use external stores (Redis, database) for shared state.
-
Return Location headers for created resources: When POST creates a resource, include a
Locationheader pointing to the new resource's URL. This follows HTTP conventions and helps clients navigate. -
Support pagination for list endpoints: Always paginate collections. Return pagination metadata (page, limit, total, pages) alongside the data. Cap the maximum limit to prevent abuse.
-
Use ETag for conditional requests: Include
ETagheaders on GET responses. Clients can sendIf-None-Matchto avoid re-downloading unchanged data, saving bandwidth. -
Keep handlers under 50 lines: If a handler is getting long, extract business logic into the service layer. Handlers should validate input, call services, and format output—nothing more.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using request.json() without try-catch | 500 error on malformed JSON requests | Always wrap in try-catch, return 400 with clear error |
| Returning sensitive data in responses | Security breach—passwords, tokens exposed | Define explicit response types, omit sensitive fields in queries |
| No pagination on list endpoints | Memory exhaustion, slow responses with large datasets | Implement cursor or offset pagination with configurable limits |
| Forgetting async params in Next.js 15 | Undefined parameter values | Always await params before accessing properties: const { id } = await params |
| In-memory rate limiting in serverless | Rate limits reset per-invocation, ineffective | Use Redis or similar external store for distributed rate limiting |
| Missing Content-Type for non-JSON responses | Client misinterprets response format | Set appropriate Content-Type: text/csv, application/pdf, etc. |
Performance Optimization
Database Query Optimization
Minimize database round-trips with selective field inclusion and proper indexing:
// Bad: Fetches all fields, including large text content
const posts = await db.posts.findMany()
// Good: Select only needed fields
const posts = await db.posts.findMany({
select: {
id: true,
title: true,
slug: true,
excerpt: true,
publishedAt: true,
author: { select: { name: true, avatar: true } },
_count: { select: { comments: true } },
},
orderBy: { publishedAt: 'desc' },
take: 20,
})Response Compression
Enable gzip compression for large JSON responses:
// next.config.ts
const nextConfig = {
compress: true, // Enabled by default
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
}For streaming responses, use chunked transfer encoding to send data progressively without buffering the entire response.
Comparison with Alternatives
| Feature | Next.js Route Handlers | Express.js | tRPC | GraphQL |
|---|---|---|---|---|
| Setup Complexity | Zero—file-based | npm install + config | Schema + router setup | Schema + resolvers |
| Type Safety | Manual (Zod) | None | Automatic | Schema-based |
| Edge Runtime | Native | Not supported | Supported | Supported |
| Colocation | With pages/components | Separate server | Shared types | Separate server |
| Streaming | Native Web Streams | Manual | Limited | Subscriptions |
| Caching | ISR integration | Manual | Manual | Complex |
| Testing | Standard fetch | Supertest | tRPC client | GraphQL client |
Advanced Patterns
Conditional Responses with ETag
Implement conditional GET to save bandwidth:
export const GET = createHandler(async (request, { params }) => {
const { id } = await params
const post = await postService.findById(id)
if (!post) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
const etag = `"${post.updatedAt.getTime()}"`
const ifNoneMatch = request.headers.get('if-none-match')
if (ifNoneMatch === etag) {
return new Response(null, { status: 304 })
}
return Response.json(post, {
headers: {
ETag: etag,
'Cache-Control': 'private, max-age=0, must-revalidate',
},
})
})Request Deduplication
Prevent duplicate processing of identical requests:
const pendingRequests = new Map<string, Promise<Response>>()
export function withDeduplication(handler: RouteHandler): RouteHandler {
return async (request, context) => {
if (request.method !== 'GET') {
return handler(request, context)
}
const key = request.url
const existing = pendingRequests.get(key)
if (existing) {
return existing.then((response) => response.clone())
}
const promise = handler(request, context)
pendingRequests.set(key, promise)
promise.finally(() => pendingRequests.delete(key))
return promise
}
}Testing Strategies
// __tests__/api/posts.test.ts
import { GET, POST } from '@/app/api/posts/route'
import { GET as GET_BY_ID, PUT, DELETE } from '@/app/api/posts/[id]/route'
import { NextRequest } from 'next/server'
const mockPost = {
id: '1',
title: 'Test Post',
slug: 'test-post',
content: 'Test content that is long enough',
authorId: 'user-1',
}
describe('Posts API', () => {
describe('GET /api/posts', () => {
it('returns paginated posts', async () => {
const request = new NextRequest('http://localhost:3000/api/posts?page=1&limit=5')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.posts).toBeDefined()
expect(data.pagination).toBeDefined()
})
it('filters by tag', async () => {
const request = new NextRequest('http://localhost:3000/api/posts?tag=nextjs')
const response = await GET(request)
expect(response.status).toBe(200)
})
})
describe('POST /api/posts', () => {
it('creates a post with valid data', async () => {
const request = new NextRequest('http://localhost:3000/api/posts', {
method: 'POST',
body: JSON.stringify({
title: 'New Post',
content: 'This is a valid post content with enough characters.',
published: true,
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(201)
expect(response.headers.get('Location')).toBeTruthy()
})
it('returns 400 for missing title', async () => {
const request = new NextRequest('http://localhost:3000/api/posts', {
method: 'POST',
body: JSON.stringify({ content: 'Content only' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
describe('GET /api/posts/[id]', () => {
it('returns a single post', async () => {
const response = await GET_BY_ID(
new NextRequest('http://localhost:3000/api/posts/1'),
{ params: { id: '1' } }
)
expect(response.status).toBe(200)
})
it('returns 404 for non-existent post', async () => {
const response = await GET_BY_ID(
new NextRequest('http://localhost:3000/api/posts/nonexistent'),
{ params: { id: 'nonexistent' } }
)
expect(response.status).toBe(404)
})
})
})Future Outlook
Route Handlers continue to evolve alongside the Web platform. The WinterCG initiative is standardizing server-side APIs across JavaScript runtimes, meaning your Route Handlers will run identically on Node.js, Deno, Bun, and Cloudflare Workers. This portability eliminates vendor lock-in and simplifies deployment decisions.
Server Actions are gaining ground for mutations initiated from React components, but Route Handlers remain essential for public APIs, webhooks, SSE, and any endpoint consumed by non-React clients. The two patterns are complementary—use Server Actions for form mutations within your app and Route Handlers for everything else.
Edge databases and edge caching are making it practical to run entire API layers at the edge. With databases like Turso, Neon, and PlanetScale offering edge-compatible drivers, Route Handlers deployed to edge runtimes can achieve single-digit millisecond response times globally.
Conclusion
Route Handlers provide a clean, standards-based API layer for Next.js applications. Their integration with the file system, Web standard APIs, and the broader Next.js ecosystem (Server Components, middleware, ISR) makes them the natural choice for backend logic in full-stack Next.js apps.
Key takeaways:
- Route Handlers use Web standard Request/Response APIs, making them portable and testable
- Use file-system routing for API endpoints—
route.tsfiles colocate with related pages - Keep handlers thin by extracting business logic into service modules
- Validate all input with Zod schemas for runtime type safety
- Implement consistent error handling with a wrapper that catches and formats all errors
- Set appropriate Cache-Control headers to leverage CDN caching for GET endpoints
- Always paginate list endpoints and cap maximum page sizes
Start by converting your Pages Router API routes to Route Handlers. The migration is straightforward—replace NextApiRequest/NextApiResponse with Web standard APIs, move to named exports for HTTP methods, and colocate handlers with their related pages. The result is a cleaner, more maintainable API layer that integrates naturally with the rest of your App Router application.