Typed Error Handling Middleware
In Next.js 16, you can compose middleware-like behaviour using higher-order functions that wrap route handlers. This is cleaner than a single monolithic middleware.ts for per-route logic.
// lib/api/with-error-handler.ts
import { NextRequest, NextResponse } from 'next/server'
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code?: string
) {
super(message)
this.name = 'ApiError'
}
}
type RouteHandler = (
req: NextRequest,
ctx: { params: Promise<Record<string, string>> }
) => Promise<NextResponse>
export function withErrorHandler(handler: RouteHandler): RouteHandler {
return async (req, ctx) => {
try {
return await handler(req, ctx)
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.statusCode }
)
}
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: 'Invalid JSON body' },
{ status: 400 }
)
}
console.error('[API Error]', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
}
// Usage:
// export const GET = withErrorHandler(async (req, ctx) => { ... })
Token-Bucket Rate Limiting with Redis
Rate limiting belongs at the route level, not just in global middleware (which can't access route-specific quotas). This implementation uses a token-bucket algorithm with Redis and is safe under concurrent load.
// lib/api/rate-limit.ts
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
interface RateLimitConfig {
key: string // e.g. ip:192.168.1.1:endpoint:/api/search
limit: number // max requests
windowSeconds: number
}
interface RateLimitResult {
allowed: boolean
remaining: number
resetAt: number
}
export async function rateLimit(config: RateLimitConfig): Promise<RateLimitResult> {
const { key, limit, windowSeconds } = config
const now = Math.floor(Date.now() / 1000)
const windowKey = `ratelimit:${key}:${Math.floor(now / windowSeconds)}`
const pipeline = redis.pipeline()
pipeline.incr(windowKey)
pipeline.expire(windowKey, windowSeconds)
const [[, count]] = await pipeline.exec() as [[null, number]]
const remaining = Math.max(0, limit - count)
const resetAt = (Math.floor(now / windowSeconds) + 1) * windowSeconds
return {
allowed: count <= limit,
remaining,
resetAt,
}
}
// Higher-order wrapper for route handlers
import { NextRequest, NextResponse } from 'next/server'
export function withRateLimit(
handler: Function,
limit = 60,
windowSeconds = 60
) {
return async (req: NextRequest, ctx: unknown) => {
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'127.0.0.1'
const endpoint = new URL(req.url).pathname
const key = `ip:${ip}:endpoint:${endpoint}`
const result = await rateLimit({ key, limit, windowSeconds })
if (!result.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please slow down.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(result.resetAt),
'Retry-After': String(result.resetAt - Math.floor(Date.now() / 1000)),
},
}
)
}
const response = await handler(req, ctx)
response.headers.set('X-RateLimit-Limit', String(limit))
response.headers.set('X-RateLimit-Remaining', String(result.remaining))
response.headers.set('X-RateLimit-Reset', String(result.resetAt))
return response
}
}
Auth Middleware with JWT Verification
Global middleware lives at middleware.ts (root of src/ or project root). In Next.js 16, the matcher supports regex and named groups. For per-route auth, use a higher-order wrapper instead.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const PUBLIC_PATHS = [
'/api/auth/login',
'/api/auth/register',
'/api/public',
]
export async function middleware(request: NextRequest): Promise<NextResponse> {
const { pathname } = request.nextUrl
// Skip public paths
if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
return NextResponse.next()
}
// Only protect /api/* routes
if (!pathname.startsWith('/api/')) {
return NextResponse.next()
}
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
// Forward user info to route handlers via headers
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', String(payload.sub))
requestHeaders.set('x-user-role', String(payload.role ?? 'user'))
return NextResponse.next({ request: { headers: requestHeaders } })
} catch {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
)
}
}
export const config = {
matcher: ['/api/:path*'],
}
Reading Auth Context in Route Handlers
The middleware sets headers — route handlers read them back to get the authenticated user without re-verifying the JWT.
// app/api/orders/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { withRateLimit } from '@/lib/api/rate-limit'
async function handler(request: NextRequest): Promise<NextResponse> {
const userId = request.headers.get('x-user-id')
const userRole = request.headers.get('x-user-role')
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (userRole !== 'admin' && userRole !== 'user') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const orders = await getOrdersByUser(userId)
return NextResponse.json({ orders })
}
export const GET = withRateLimit(handler, 30, 60)
Composing Multiple Middleware Wrappers
Compose wrappers with a utility function to avoid deeply nested calls. Each wrapper receives the handler from the previous layer.
// lib/api/compose.ts
type Wrapper = (handler: Function) => Function
export function compose(...wrappers: Wrapper[]) {
return (handler: Function) =>
wrappers.reduceRight((h, wrapper) => wrapper(h), handler)
}
// Usage in route file:
import { compose } from '@/lib/api/compose'
import { withErrorHandler } from '@/lib/api/with-error-handler'
import { withRateLimit } from '@/lib/api/rate-limit'
import { withValidation } from '@/lib/api/with-validation'
import { z } from 'zod'
const CreateOrderSchema = z.object({
productId: z.string().min(1),
quantity: z.number().int().positive().max(100),
couponCode: z.string().optional(),
})
async function createOrder(request: NextRequest): Promise<NextResponse> {
const body = await request.json()
// body is already validated by withValidation
const order = await processOrder(body)
return NextResponse.json(order, { status: 201 })
}
export const POST = compose(
withErrorHandler,
(h) => withRateLimit(h, 10, 60),
(h) => withValidation(h, CreateOrderSchema)
)(createOrder)
Request Validation with Zod
// lib/api/with-validation.ts
import { NextRequest, NextResponse } from 'next/server'
import { z, ZodSchema } from 'zod'
export function withValidation<T>(handler: Function, schema: ZodSchema<T>) {
return async (request: NextRequest, ctx: unknown) => {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = schema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
issues: result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
{ status: 422 }
)
}
// Attach validated data to request for handler
;(request as NextRequest & { validatedBody: T }).validatedBody = result.data
return handler(request, ctx)
}
}
People Also Ask
Can I still use pages/api in Next.js 16?
No. The Pages Router and pages/api/ are fully deprecated in Next.js 16. All API routes must use the App Router at app/api/**/route.ts. If you are migrating, the function signature and export pattern are different — route handlers export named functions (GET, POST, etc.) instead of a default export.
How does middleware run in Next.js 16 compared to earlier versions?
Middleware still runs on the Edge Runtime at the CDN level, before your route handlers execute. The key change in Next.js 16 is that params in route context is now a Promise — you must await context.params before accessing values. Forgetting this is the most common migration bug.
What is the best way to handle authentication in Next.js 16 API routes?
The recommended pattern is: verify the JWT in global middleware.ts, forward the user identity as headers (x-user-id, x-user-role), and read those headers inside route handlers. This avoids re-verifying tokens on every request and keeps route handler code clean. Use jose for JWT verification — it is Edge Runtime compatible, unlike jsonwebtoken.
Comments · 0
No comments yet. Be the first to share your thoughts.