Next.js middleware runs at the edge before any route handler or Server Component. It's one of the most powerful and misunderstood features of the framework. My portfolio site (matthewswong.com) uses middleware for i18n locale detection with next-intl. ERP applications I've built use middleware for authentication redirects and CSP headers. Here's how middleware actually works in production and where it fits — and where it doesn't.
Middleware is a function that runs on the Edge Runtime before a request reaches your route handler, Server Component, or static file. It can read the request, modify headers, rewrite the URL, redirect, or return a response directly. What it is NOT: middleware is not a secure authorization layer. The CVE-2025-29927 vulnerability demonstrated that middleware can be bypassed by sending a crafted x-middleware-subrequest header. Authorization must happen inside your route handlers and Server Components — middleware handles routing decisions.
The most common middleware use case in Next.js is internationalization. next-intl's middleware detects the user's preferred locale from: (1) the URL prefix (/en/, /id/), (2) a locale cookie set by a previous visit, (3) the Accept-Language request header. It then redirects or rewrites the request to the appropriate locale path. My portfolio site uses this pattern — visiting matthewswong.com with an Indonesian browser locale redirects to /id automatically. The middleware adds a response cookie to remember the choice for future visits.
Request lifecycle with Next.js middleware:
Incoming Request
│
▼
middleware.ts (Edge Runtime)
│
├── 1. i18n: detect locale from URL/cookie/Accept-Language
│ → rewrite /products → /en/products (or /id/products)
│
├── 2. Auth: check session cookie / JWT
│ → if missing: redirect to /[locale]/login
│ → if present: continue (actual authz in route handler)
│
├── 3. Security headers: add CSP, HSTS, X-Frame-Options
│
└── 4. Pass to route handler / Server Component
│
▼
Route Handler (actual authorization lives here)
Server Component (data fetching, rendering)
Middleware matcher — CRITICAL for performance:
export const config = {
matcher: [
'/((?!_next/static|_next/image|images|favicon.ico|api/health).*)',
],
}
// Without this: middleware runs on EVERY request including static filesFrom integrating next-intl middleware with Clerk authentication on matthewswong.com's admin routes: compose middleware functions using createMiddleware from next-intl and wrap it with Clerk's authMiddleware. Run i18n detection first, then auth check, so redirects go to the correct locale's login page (e.g., /id/login rather than /en/login). The order matters — auth middleware needs to know the locale to construct the correct redirect URL.
Middleware is appropriate for authentication redirects — checking if a session cookie exists and redirecting to /login if not. What it's NOT appropriate for: checking permissions, roles, or resource-level access. The reason: middleware runs on the Edge Runtime, which has limited access to your database or complex business logic. Check the session token validity in middleware (a JWT verification is fast and CPU-bound), redirect unauthenticated users, but leave the actual authorization (can this user access this resource?) to the Server Component or Route Handler.
// middleware.ts — production example (i18n + auth + CSP headers)
import { NextRequest, NextResponse } from 'next/server'
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
const intlMiddleware = createMiddleware(routing)
const PROTECTED_PATHS = ['/dashboard', '/admin', '/settings']
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname
// Run i18n middleware first (handles locale detection + redirect)
const intlResponse = intlMiddleware(req)
// Add security headers to every response
const response = intlResponse ?? NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'"
)
// Auth check for protected routes (JWT from cookie)
const isProtected = PROTECTED_PATHS.some(p => pathname.includes(p))
if (isProtected) {
const token = req.cookies.get('auth-token')?.value
if (!token) {
// Extract locale from pathname for correct redirect
const locale = pathname.startsWith('/id') ? 'id' : 'en'
return NextResponse.redirect(new URL(`/${locale}/login`, req.url))
}
// NOTE: Don't verify JWT here for complex auth — do that in the route handler
// Middleware just checks for presence; route handler does actual verification
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|images|favicon.ico|api/health).*)'],
}
// A/B testing at the edge
export function middleware(req: NextRequest) {
const bucket = req.cookies.get('ab-bucket')?.value ?? (Math.random() > 0.5 ? 'a' : 'b')
const url = req.nextUrl.clone()
url.pathname = `/landing-${bucket}`
const response = NextResponse.rewrite(url)
response.cookies.set('ab-bucket', bucket, { maxAge: 60 * 60 * 24 * 30 })
return response
}Middleware is an excellent place to add security response headers globally — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Setting these in middleware ensures every response (pages, API routes, static files served via middleware) gets the headers. The alternative — setting them in next.config.js headers() — only applies to page routes. For CSP headers with nonces (required for inline scripts), middleware is the only place to generate the nonce and attach it to both the header and the rendering context.
By default, Next.js middleware runs on EVERY request including static files, images, and API routes. Use the matcher config to limit which paths trigger your middleware. A poorly configured middleware that runs on every /_next/image request adds latency to every image load. At minimum, exclude /_next/static, /_next/image, /images/, and /favicon.ico from your matcher. Profile your middleware execution time in Vercel's edge function logs — even 5ms on every image request adds up.
Middleware enables true server-side A/B testing without client-side flicker. Assign a user to a variant (using a cookie or the user ID from a JWT), then rewrite the URL to the appropriate page: /landing-a or /landing-b, both accessible at /. The user sees /campaign in the URL but gets the variant page. Because this happens at the edge before any rendering, there's no flash of the control variant before the test variant loads — a significant advantage over client-side A/B testing.
On every Next.js project, my middleware.ts handles: (1) i18n locale detection and redirection via next-intl. (2) Session check and auth redirect for protected routes. (3) Security headers (CSP, HSTS, X-Frame-Options). That's it. I don't put business logic, database calls, or complex authorization in middleware. The Edge Runtime limitations (no Node.js APIs, limited npm package support) make complex middleware fragile. Keep middleware lean — it runs on every request.
Next.js supports only one middleware.ts file at the project root. For complex middleware with multiple concerns (auth + i18n + CSP), I chain them using a compose pattern: export the main function that runs each concern in sequence and merges the response headers. next-intl provides a createMiddleware factory that handles i18n and returns a middleware function you can compose with. Clerk provides clerkMiddleware() that wraps your middleware logic. Test your middleware composition carefully — incorrect response merging can strip headers or lose redirect information.