Next.js Middleware: Auth, Redirects, A/B Tests at the Edge
Next.js middleware runs at the edge before your page loads. Here's how to wire up auth guards, geo-redirects, and A/B tests without touching your React components.
What Next.js Middleware Actually Is
Honestly, most developers treat middleware like a fancy afterthought — something you bolt on when auth gets complicated. That's backwards. Middleware runs at the edge, before your React tree even starts rendering, which means it's one of the few places you can intercept a request with near-zero latency.
In Next.js 13.1+ (and solidly refined through 14.x and 15.x), the middleware.ts file sits at the root of your project and exports a single middleware function. The runtime is a trimmed-down V8 isolate — no Node.js APIs, no fs, no crypto from Node. You get the Web Fetch API, Request, Response, and NextResponse from Next.js itself.
Why does the runtime restriction matter? Because it forces you to write lean, fast logic. Milliseconds count here. If your middleware is doing 400ms database queries, you're defeating the whole point. Think of it as the bouncer at the door, not the maître d' inside.
Setting Up Your middleware.ts File
The file structure is simple. Drop middleware.ts at the project root (same level as app/ or pages/). Export a middleware function and optionally a config object to define which paths it runs on. Without the config matcher, middleware runs on every single request — including _next/static, images, and favicons. You don't want that.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip API routes and static files
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) {
return NextResponse.next()
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all request paths EXCEPT:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
* - public folder files
*/
'/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
]
}The matcher regex looks intimidating but it's just a negative lookahead. You write this once and forget about it. From this baseline you layer in whatever logic you need.
Auth Guards: Protecting Routes Without a Library
Here's the most common use case: redirect unauthenticated users away from protected routes. The pattern is read a session token from cookies, validate it (or at minimum check it exists), and either continue or redirect to /login.
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 PROTECTED_PATHS = ['/dashboard', '/settings', '/billing']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
const token = request.cookies.get('session')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
try {
await jwtVerify(token, JWT_SECRET)
return NextResponse.next()
} catch {
// Token expired or tampered with
const response = NextResponse.redirect(new URL('/login', request.url))
response.cookies.delete('session')
return response
}
}Notice we're using jose instead of jsonwebtoken. That's intentional — jose is built for Web Crypto APIs and works in the edge runtime. jsonwebtoken uses Node's crypto module and will throw at build time. This is a common stumbling block.
One thing worth noting: this approach validates the JWT signature but doesn't check a revocation list. If you need that, you'll need a fast key-value store like Upstash Redis with an HTTP API — regular TCP connections aren't available in edge functions.
Geo-Based Redirects and Localization
Next.js middleware exposes geo data on the request object when deployed to Vercel (via request.geo). On other platforms you'll parse headers like CF-IPCountry (Cloudflare) or X-Vercel-IP-Country. Either way, the pattern is the same: read a header, decide where to send the user.
export function middleware(request: NextRequest) {
// Vercel injects this automatically
const country = request.geo?.country ??
request.headers.get('CF-IPCountry') ??
'US'
const { pathname } = request.nextUrl
// Only redirect the root path
if (pathname === '/') {
if (country === 'FR' || country === 'BE') {
return NextResponse.redirect(new URL('/fr', request.url))
}
if (country === 'DE' || country === 'AT' || country === 'CH') {
return NextResponse.redirect(new URL('/de', request.url))
}
}
return NextResponse.next()
}Keep redirect chains short. Redirecting / → /fr → /fr/home adds two round trips. If you're building a heavily localized app, pair this with Next.js's built-in i18n routing rather than rolling everything yourself. Middleware is good for the initial country detection redirect; next.config.js i18n handles the rest.
You can also use this for feature flags scoped by region — for example, showing a new checkout flow only to US users while the EU version stays stable during an experiment.
A/B Testing at the Edge: The Right Way
A/B testing in middleware works by assigning users a variant via a cookie, then rewriting the URL to a different page path — all without a redirect. The user's URL stays the same. The served content is different. That's the magic of NextResponse.rewrite().
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const EXPERIMENT_COOKIE = 'ab_pricing_v2'
const VARIANTS = ['control', 'treatment'] as const
type Variant = typeof VARIANTS[number]
function assignVariant(): Variant {
return Math.random() < 0.5 ? 'control' : 'treatment'
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname !== '/pricing') return NextResponse.next()
let variant = request.cookies.get(EXPERIMENT_COOKIE)?.value as Variant | undefined
if (!variant || !VARIANTS.includes(variant)) {
variant = assignVariant()
}
const rewriteUrl = new URL(
variant === 'treatment' ? '/pricing-v2' : '/pricing',
request.url
)
const response = NextResponse.rewrite(rewriteUrl)
// Persist assignment for 30 days
response.cookies.set(EXPERIMENT_COOKIE, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax'
})
return response
}Why do this at the edge instead of in a React component? Two reasons. First, there's no layout shift — the user gets the right page immediately, not after a client-side hydration check. Second, search engines see a consistent version of your page since the rewrite is server-side. If you toggle variants in React, you risk Google indexing the wrong content.
The tradeoff is that you need actual separate page files (/pricing and /pricing-v2). You can't conditionally render components from middleware. Plan your file structure before you start. Also worth reading our React performance guide if you want to understand what happens after the rewrite lands — hydration and bundle size still matter.
Chaining Multiple Middleware Concerns
Real apps need auth AND geo redirects AND A/B tests. Next.js only supports one middleware file, so you compose everything inside it. The order matters. Auth should run first — no point geo-redirecting an unauthenticated user to the French version of a dashboard they can't access anyway.
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. Skip static assets early
if (pathname.match(/\.(ico|png|jpg|svg|css|js)$/)) {
return NextResponse.next()
}
// 2. Auth check on protected paths
const authResult = await checkAuth(request)
if (authResult) return authResult // returns redirect if unauthed
// 3. Geo redirect (root only)
const geoResult = handleGeoRedirect(request)
if (geoResult) return geoResult
// 4. A/B test rewrites
const abResult = handleABTest(request)
if (abResult) return abResult
return NextResponse.next()
}Break each concern into its own function that returns NextResponse | null. If it returns null, move to the next concern. This keeps your middleware readable as it grows. If you're into typed patterns, you'll appreciate how this pairs with TypeScript utility types to make each handler's return type explicit.
Watch your total middleware execution time. Vercel's edge functions have a 1500ms wall clock limit. If you're doing multiple async operations (like two separate token validations), run them in parallel with Promise.all when they're independent.
Headers, CSP, and Response Manipulation
Middleware can modify both request and response headers. This makes it a natural place to inject Content Security Policy headers, set CORS headers for API routes, or pass request metadata to your pages via custom headers.
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
// Pass the pathname to server components via header
requestHeaders.set('x-pathname', request.nextUrl.pathname)
const response = NextResponse.next({
request: { headers: requestHeaders }
})
// Content Security Policy
response.headers.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.example.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://images.unsplash.com",
"connect-src 'self' https://api.example.com"
].join('; ')
)
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')
return response
}The x-pathname trick is genuinely useful. In server components you can read headers().get('x-pathname') to know the current route without parsing the URL again. It's a clean way to pass edge-computed data downstream without prop drilling or cookies.
For CSP specifically, be careful with 'unsafe-inline' for scripts — it weakens your protection against XSS. The proper solution is nonce-based CSP where you generate a random nonce in middleware, set it in the header, and thread it through your <Script> tags. That's a longer topic, but it's worth doing if you're handling sensitive user data. Also check out how glassmorphism UI patterns and toast notifications handle their inline styles — you'll need to account for those in your CSP allowlist.
Testing and Debugging Middleware Locally
Middleware is notoriously hard to test because the edge runtime isn't Node.js. You can't just import your middleware file into Jest and call it. The best approach is to use Next.js's built-in dev server and write integration tests with Playwright or a lightweight HTTP client that hits localhost:3000.
For unit testing individual helper functions (like assignVariant() or checkAuth()), extract them into separate files that don't import any Next.js-specific APIs. Those files are perfectly testable with Vitest. The middleware file itself just orchestrates calls to those pure functions.
Locally, console.log in middleware prints to your terminal (the Next.js dev server process), not the browser. When deployed on Vercel, you'll find edge function logs in the Functions tab of your project dashboard. Add structured logging with a requestId header — it makes correlating logs across distributed edge nodes much easier when something goes wrong.
One more gotcha: environment variables in middleware must be defined in next.config.js under env or prefixed with NEXT_PUBLIC_. Server-only variables accessed via process.env work during local dev but may behave differently across edge runtimes. Test with next build && next start before assuming your prod behaviour matches dev.
FAQ
No. The edge runtime doesn't support TCP connections, which most database clients require. You'd need an HTTP-based database API (like PlanetScale's HTTP driver, Supabase's REST API, or Upstash Redis) to fetch data from middleware. For anything heavier, do it in a server action or API route instead.
By default, yes — unless you define a matcher in your config export. Always add a matcher that excludes _next/static, _next/image, and common static file extensions. Running middleware on every image request adds unnecessary latency and can exhaust your edge function invocation limits.
redirect() sends a 307 or 308 HTTP response to the browser, changing the URL in the address bar. rewrite() silently serves a different page without the URL changing — the browser never knows a rewrite happened. Use redirect for auth flows and canonical URLs; use rewrite for A/B tests and internationalisation.
Set a custom request header in middleware with the verified session data (e.g., user ID, role), then read it in your server component via headers().get('x-user-id'). Avoid putting sensitive data in headers though — headers can be logged. For sensitive fields, keep them in a cookie that the server component reads directly.
Usually because your auth redirect points to /login, but /login also matches your auth guard. Fix this by explicitly excluding auth-related paths from your protected paths list, or by adding /login and /register to the beginning of your middleware's early-return conditions.
Yes, but with caveats. Netlify Edge Functions support the same Web API standard and Next.js middleware works there. On traditional Lambda (not Lambda@Edge), requests run in a Node.js environment and some edge-specific features like request.geo won't be available. Check your hosting provider's Next.js compatibility docs before relying on edge-specific APIs.