Middleware: Authentication, Redirects and A/B Testing
What Middleware Is and Where It Runs
Next.js Middleware is code that executes before a request reaches a page or API route. It runs on the Edge Runtime — a lightweight V8 isolate sandbox that is completely isolated from the Node.js process. This means Middleware cannot use fs, child_process, or any npm package that relies on Node.js native APIs.
Why Edge Runtime? The answer is speed. A traditional Node.js server needs hundreds of milliseconds to load its runtime and modules on a cold start, whereas a V8 isolate starts in single-digit milliseconds. Edge networks like Vercel and Cloudflare Workers deploy the Edge Runtime on dozens of nodes worldwide, allowing Middleware to execute on the node physically closest to the user, dramatically reducing Time to First Byte (TTFB).
The Middleware file must live in the project root (alongside app/), named middleware.ts:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
return NextResponse.next()
}
matcher: Precisely Controlling Which Paths Trigger Middleware
By default, Middleware applies to every route, including static assets. This wastes resources unnecessarily. The matcher config lets you specify exactly which paths Middleware should handle:
export const config = {
matcher: [
// All paths except static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
// Only protect /dashboard and its children
'/dashboard/:path*',
// API routes
'/api/:path*',
],
}
The matcher supports regex syntax — quantifiers like ?, *, and + all work. Path parameters use :param notation, and :path* matches zero or more path segments.
Why Careful matcher Design Matters
Running Middleware on every request adds latency even when the function body does nothing — there is always a fixed overhead. More importantly, static assets under _next/static are meant to be served directly from a CDN or the filesystem. Intercepting them with Middleware bypasses that optimization. In production, always explicitly exclude static asset paths.
The Three Modes of NextResponse
Middleware controls request flow by returning different types of NextResponse:
NextResponse.next() — passes the request through for normal processing. You can modify request headers when passing through, forwarding data to downstream Server Components or Route Handlers:
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-pathname', request.nextUrl.pathname)
requestHeaders.set('x-user-country', request.geo?.country ?? 'unknown')
return NextResponse.next({
request: { headers: requestHeaders },
})
}
Reading those headers in a Server Component:
// app/dashboard/page.tsx
import { headers } from 'next/headers'
export default async function DashboardPage() {
const headersList = await headers()
const country = headersList.get('x-user-country')
return <div>Your country: {country}</div>
}
NextResponse.redirect() — sends an HTTP redirect to the client (307 Temporary Redirect by default):
return NextResponse.redirect(new URL('/login', request.url))
// Or a permanent redirect
return NextResponse.redirect(new URL('/new-path', request.url), 301)
NextResponse.rewrite() — silently rewrites the request destination on the server side; the URL remains unchanged in the browser. This is the foundation of A/B testing and multi-tenant routing:
return NextResponse.rewrite(new URL('/variant-b/page', request.url))
JWT Authentication Protection: A Complete Implementation
Authentication is the most common use case for Middleware. Below is a production-grade JWT verification implementation using the Edge-compatible jose library (which does not depend on Node.js's crypto module):
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET!
)
const PUBLIC_ROUTES = ['/login', '/register', '/api/auth']
async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload
} catch {
return null
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Let public routes through immediately
if (PUBLIC_ROUTES.some(route => pathname.startsWith(route))) {
return NextResponse.next()
}
const token = request.cookies.get('auth-token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
const payload = await verifyToken(token)
if (!payload) {
// Token is invalid or expired — clear the cookie and redirect
const response = NextResponse.redirect(
new URL('/login', request.url)
)
response.cookies.delete('auth-token')
return response
}
// Forward user identity to downstream handlers
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', String(payload.sub))
requestHeaders.set('x-user-role', String(payload.role))
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Why jose Instead of jsonwebtoken
jsonwebtoken depends on Node.js's crypto module, which is unavailable in the Edge Runtime. jose uses the Web Crypto API and is the go-to JWT library for Edge and browser environments. The computational overhead of verifying a JWT is negligible — typically under one millisecond — so it will never become a bottleneck.
Working with Cookies in Middleware
Cookies are the primary mechanism for Middleware to interact with client-side state. NextRequest provides a type-safe cookie API:
// Reading cookies
const theme = request.cookies.get('theme')?.value
const allCookies = request.cookies.getAll()
// Setting cookies on the response
const response = NextResponse.next()
response.cookies.set('theme', 'dark', {
httpOnly: false, // Readable by client-side JS
secure: true, // HTTPS only
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
})
// Deleting a cookie
response.cookies.delete('old-session')
A/B Testing: Cookie-Based Traffic Bucketing
The core requirement of A/B testing is that the same user always sees the same variant (stickiness), while different users are randomly assigned to groups. Middleware is a natural fit for this:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
type Variant = 'control' | 'treatment'
function assignVariant(): Variant {
return Math.random() < 0.5 ? 'control' : 'treatment'
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Only A/B test the homepage
if (pathname !== '/') {
return NextResponse.next()
}
let variant = request.cookies.get('ab-variant')?.value as Variant | undefined
// New user: assign a variant and persist it
if (!variant || !['control', 'treatment'].includes(variant)) {
variant = assignVariant()
}
// Rewrite traffic to the corresponding variant page
// /app/ab/control/page.tsx and /app/ab/treatment/page.tsx
const url = request.nextUrl.clone()
url.pathname = `/ab/${variant}`
const response = NextResponse.rewrite(url)
// Persist the assignment for 30 days
response.cookies.set('ab-variant', variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
})
return response
}
The file structure for variant pages:
app/
ab/
control/
page.tsx ← Original version
treatment/
page.tsx ← Experimental version
page.tsx ← Never actually reached (intercepted by rewrite)
Collecting Experiment Data
Record exposure events in variant pages and send them to an analytics service:
// app/ab/treatment/page.tsx
import { headers } from 'next/headers'
import { track } from '@/lib/analytics'
export default async function TreatmentPage() {
// Record exposure server-side (Server Component)
await track('experiment_exposure', {
experiment: 'homepage-redesign',
variant: 'treatment',
})
return <NewHomepage />
}
Geolocation-Based Redirects
Platforms like Vercel expose geolocation data through request headers in the Edge Runtime, and NextRequest wraps this in a request.geo object:
export function middleware(request: NextRequest) {
const country = request.geo?.country
const { pathname } = request.nextUrl
// Already on a language-specific path — do nothing
if (pathname.startsWith('/zh') || pathname.startsWith('/en')) {
return NextResponse.next()
}
if (country === 'CN') {
return NextResponse.redirect(
new URL(`/zh${pathname}`, request.url)
)
}
// Default to English
return NextResponse.redirect(
new URL(`/en${pathname}`, request.url)
)
}
Note that request.geo is only populated when deployed on a platform that provides geolocation data (such as Vercel). During local development it is undefined, so always handle that gracefully.
Edge Runtime Limitations and Workarounds
The Edge Runtime's constraints stem from its secure sandbox design. Here are the common issues and solutions:
| Limitation | Reason | Solution |
|---|---|---|
No fs module |
V8 isolate has no filesystem access | Compile file contents into the bundle or fetch via API |
No Node.js crypto |
Use Web Crypto API instead | Switch to jose, @noble/ crypto libraries |
No child_process |
Sandbox restriction | Not suitable for Middleware execution |
| 128 MB memory limit | Multi-tenant shared environment | Avoid loading large data structures in Middleware |
| No persistent connections | Stateless execution | Do database queries in Server Components |
One commonly overlooked trap: connecting to a database inside Middleware to verify a user. Creating a database connection on every single request is extremely expensive. Instead, verify a JWT (no database query needed) or call a lightweight KV store like Upstash Redis.
Composing Multiple Middleware Functions
As business complexity grows, a single Middleware function can become bloated. The recommended pattern is to split responsibilities into independent functions and compose them in sequence in the main Middleware entry point:
// middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { withAuth } from './middlewares/auth'
import { withI18n } from './middlewares/i18n'
import { withRateLimit } from './middlewares/rateLimit'
type MiddlewareFn = (
request: NextRequest,
next: () => Promise<NextResponse>
) => Promise<NextResponse>
function chain(middlewares: MiddlewareFn[]) {
return async (request: NextRequest): Promise<NextResponse> => {
let index = 0
async function next(): Promise<NextResponse> {
if (index >= middlewares.length) {
return NextResponse.next()
}
const current = middlewares[index++]
return current(request, next)
}
return next()
}
}
export const middleware = chain([
withRateLimit,
withAuth,
withI18n,
])
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
This chain pattern keeps each middleware focused on a single responsibility, making it easy to test and maintain independently. Each middleware can either return early (short-circuit) or call next() to pass control to the next function in the chain.
Summary
Middleware is the frontmost guard in the Next.js request pipeline, executing on the Edge Runtime with minimal latency. It is the right tool for authentication checks, redirects, A/B traffic splitting, and geolocation routing — any logic that requires a fast decision on every request. The key constraint is the Edge Runtime's sandbox: no Node.js APIs, no direct database connections. These constraints push us toward lighter, stateless implementations that actually improve scalability. Mastering precise matcher configuration and understanding when to use each of the three NextResponse modes is the foundation of using Middleware effectively.