end and user retention.
Core Solution
Implementing production-grade Next.js middleware requires strict adherence to edge constraints, deliberate matcher scoping, and concern separation. The following architecture demonstrates a scalable pattern that handles authentication verification, geo-based routing, and header injection without violating runtime limits.
Step 1: Define Precision Matchers
Middleware executes only on routes matching the config.matcher array. Broad patterns trigger unnecessary invocations. Scope matchers to dynamic routes, API endpoints, and protected pages.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
The negative lookahead ((?!_next/static|_next/image|favicon.ico|public/).*) excludes static assets, reducing edge invocation volume by ~40% in typical applications.
Step 2: Structure by Concern
Avoid monolithic middleware files. Split logic into composable functions that return NextResponse or undefined. This enables tree-shaking, simplifies testing, and prevents CPU budget exhaustion.
// middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function verifyAuth(req: NextRequest) {
const token = req.cookies.get('session')?.value
if (!token) return NextResponse.redirect(new URL('/login', req.url))
try {
await jwtVerify(token, SECRET)
return NextResponse.next()
} catch {
const res = NextResponse.redirect(new URL('/login', req.url))
res.cookies.delete('session')
return res
}
}
Step 3: Implement Edge-Safe Geo Routing
Use request headers instead of external lookups. The Edge Runtime exposes req.geo and req.ip without network calls.
// middleware/geo.ts
import { NextRequest, NextResponse } from 'next/server'
export function applyGeoRouting(req: NextRequest) {
const country = req.geo?.country ?? 'US'
const region = country === 'DE' || country === 'FR' ? 'eu' : 'na'
const res = NextResponse.next()
res.headers.set('x-edge-region', region)
res.headers.set('x-middleware-cache', 'private, max-age=60')
return res
}
Step 4: Compose in Main Handler
Chain concerns sequentially. Early returns prevent unnecessary execution.
// middleware.ts (continued)
import { verifyAuth } from './middleware/auth'
import { applyGeoRouting } from './middleware/geo'
export async function middleware(req: NextRequest) {
// 1. Skip auth for public API routes
if (req.nextUrl.pathname.startsWith('/api/public')) {
return applyGeoRouting(req)
}
// 2. Verify authentication
const authRes = await verifyAuth(req)
if (authRes instanceof NextResponse && authRes.status === 307) {
return authRes
}
// 3. Apply routing/headers
return applyGeoRouting(req)
}
Architecture Decisions & Rationale
- Edge Runtime Default: Next.js middleware runs on Edge by design. Node.js APIs (
fs, net, crypto, process.env with complex parsing) are unavailable. Use jose for JWT verification instead of jsonwebtoken to maintain Edge compatibility.
- Local Token Validation: Remote API calls for session validation violate the 50ms CPU limit. Store tokens as signed cookies or use Vercel KV for short-lived session state.
- Header Caching:
x-middleware-cache: private, max-age=60 instructs Vercel's edge network to cache middleware responses for identical requests, reducing compute repetition.
- Rewrites vs Redirects: Use
NextResponse.rewrite() for internal routing (preserves URL, lower latency). Use NextResponse.redirect() only for authentication failures or explicit user navigation.
Pitfall Guide
1. Overly Broad Matchers
Using /:path* or omitting config.matcher forces middleware to execute on every request, including static files, images, and favicon. This inflates edge costs and increases cold start probability.
Best Practice: Explicitly whitelist dynamic routes. Use negative lookaheads to exclude _next/static, _next/image, and public/.
2. Node.js API Usage in Edge Runtime
Importing fs, path, crypto, or using require() triggers runtime errors. The Edge Runtime uses Web APIs, not Node.js globals.
Best Practice: Audit imports with next build. Replace Node modules with Web-compatible alternatives (jose, @edge-runtime/cookies, crypto.subtle).
3. Remote Validation & Database Queries
HTTP calls to auth providers or databases exceed the 50ms CPU budget. Edge isolates do not support persistent connections or connection pooling.
Best Practice: Validate tokens locally. Use Vercel KV or Redis for session state. Cache verification results with x-middleware-cache.
Middleware responses are not cached by default. Repeated identical requests trigger redundant execution, wasting compute.
Best Practice: Set x-middleware-cache: private, max-age=<seconds> for deterministic responses. Use public only for truly static transformations.
5. Middleware as Authentication Gate
Embedding complex auth logic, role checks, and permission resolution in middleware creates tight coupling and increases failure surface.
Best Practice: Use middleware only for token verification and routing. Delegate authorization to server components or API routes where full runtime context is available.
6. Synchronous JSON Parsing
req.json() or JSON.parse() on large payloads blocks the V8 isolate. Edge functions lack streaming parsers optimized for middleware.
Best Practice: Parse only when necessary. Validate content length headers first. Stream API payloads to origin handlers instead of middleware.
7. Missing Error Boundaries
Uncaught exceptions in middleware return 500 responses without fallback routing, breaking user flows.
Best Practice: Wrap external calls in try/catch. Return NextResponse.next() on non-critical failures. Log errors to Vercel Runtime Logs or Sentry Edge.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Session-based auth with JWT | Local verification + cookie routing | Eliminates remote calls, stays within 50ms CPU limit | -45% edge compute |
| A/B testing with user segments | Header-based routing + KV state | Avoids DB queries, enables edge caching | -30% latency |
| Geo-restricted content delivery | req.geo + rewrite rules | Uses built-in Vercel headers, zero network I/O | -20% infrastructure |
| API rate limiting | Edge KV + sliding window | Persistent state without origin round-trips | +15% KV usage, -60% origin load |
| Static site with dynamic previews | Matcher exclusion + preview mode cookies | Prevents middleware on static assets | -55% invocation volume |
Configuration Template
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
async function verifySession(req: NextRequest) {
const token = req.cookies.get('session')?.value
if (!token) return NextResponse.redirect(new URL('/login', req.url))
try {
await jwtVerify(token, JWT_SECRET)
return NextResponse.next()
} catch {
const res = NextResponse.redirect(new URL('/login', req.url))
res.cookies.delete('session')
return res
}
}
function applyEdgeHeaders(req: NextRequest) {
const res = NextResponse.next()
res.headers.set('x-edge-region', req.geo?.country ?? 'unknown')
res.headers.set('x-middleware-cache', 'private, max-age=60')
return res
}
export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/api/public')) {
return applyEdgeHeaders(req)
}
const auth = await verifySession(req)
if (auth.status === 307) return auth
return applyEdgeHeaders(req)
}
Quick Start Guide
- Create the file: Add
middleware.ts to your project root. Next.js automatically detects and bundles it.
- Configure matchers: Replace the
matcher array with your protected routes and API paths. Use negative lookaheads to exclude static assets.
- Install edge-compatible dependencies: Run
npm i jose for JWT verification. Remove any Node.js-specific imports.
- Set environment variables: Export
JWT_SECRET (minimum 32 characters) in your .env.local or Vercel dashboard.
- Deploy and monitor: Push to Vercel. Check Edge CPU metrics in the dashboard. Verify
x-middleware-cache headers appear in network responses. Adjust matchers if invocation waste exceeds 15%.