Next.js Route Handlers replace the old pages/api directory. They're more powerful and align with Web API standards. Here's how to build production-quality APIs with them.
Basic Structure
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const users = await db.users.findMany({
skip: (page - 1) * 20,
take: 20,
orderBy: { createdAt: 'desc' }
})
return NextResponse.json({ data: users, page })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.users.create({ data: body })
return NextResponse.json({ data: user }, { status: 201 })
}
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const user = await db.users.findById(id)
if (!user) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: 'User not found' } },
{ status: 404 }
)
}
return NextResponse.json({ data: user })
}
Middleware for Auth
// lib/api-middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from './auth'
type Handler = (req: NextRequest, context: any, user: User) => Promise<NextResponse>
export function withAuth(handler: Handler) {
return async (req: NextRequest, context: any) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'No token provided' } },
{ status: 401 }
)
}
const user = await verifyToken(token)
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Invalid token' } },
{ status: 401 }
)
}
return handler(req, context, user)
}
}
// Usage
export const GET = withAuth(async (req, context, user) => {
const data = await getDataForUser(user.id)
return NextResponse.json({ data })
})
Request Validation
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['user', 'admin']).default('user')
})
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON' } },
{ status: 400 }
)
}
const parsed = CreateUserSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: parsed.error.flatten().fieldErrors
}
},
{ status: 400 }
)
}
const user = await db.users.create({ data: parsed.data })
return NextResponse.json({ data: user }, { status: 201 })
}
Error Handling
// lib/api-errors.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string
) {
super(message)
}
}
// Wrap handlers to catch errors
export function withErrorHandling(handler: (req: NextRequest, ctx: any) => Promise<NextResponse>) {
return async (req: NextRequest, ctx: any) => {
try {
return await handler(req, ctx)
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json(
{ error: { code: error.code, message: error.message } },
{ status: error.statusCode }
)
}
console.error('Unhandled error:', error)
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 }
)
}
}
}
Rate Limiting
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
})
export function withRateLimit(handler: Handler) {
return async (req: NextRequest, ctx: any) => {
const identifier = req.headers.get('x-forwarded-for') || 'anonymous'
const { success, reset } = await ratelimit.limit(identifier)
if (!success) {
return NextResponse.json(
{ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) }
}
)
}
return handler(req, ctx)
}
}
CORS for External Consumers
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
})
}
function corsHeaders(origin: string) {
return {
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin'
}
}
Compose these middlewares for clean route handlers:
export const POST = withErrorHandling(withRateLimit(withAuth(
async (req, ctx, user) => {
// Clean handler logic here
const data = await doWork(user)
return NextResponse.json({ data })
}
)))