Auth guards, redirects, rewrites, A/B testing, rate limiting, i18n routing, security headers, and geolocation — with real production code for each.
Next.js middleware runs on every request — before the page renders, before the API route executes, before anything. It runs at the Edge, close to the user, with sub-millisecond overhead. And it has access to the full request: headers, cookies, URL, and geolocation.
That combination makes middleware the right place for a set of patterns that are awkward to implement anywhere else: auth guards that work for both pages and API routes, geolocation-based redirects, A/B testing without layout shift, and header injection without touching every single component.
This guide covers middleware from basics to production patterns with real code.
What Middleware Can Do
Middleware runs between the request and the response. It can:
- Read and modify headers — add auth headers, security headers, CORS
- Redirect — based on auth state, locale, feature flags
- Rewrite — transparently serve different pages for the same URL
- Set and read cookies — for auth sessions, A/B test buckets
- Return responses early — block requests without hitting your server
- Access geolocation (Vercel only) — route based on country/region
What it can't do: access the database, use Node.js APIs (no fs, no crypto from Node), or run heavy computation. Middleware is Edge Runtime only — Node.js APIs are unavailable.
No fs, no path, no crypto from Node.js. For JWT verification use jose (Web Crypto API). For DB access, pass data via headers to your Server Components or API routes.
Setup
Create middleware.ts at the root of your project (same level as app/):
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
return NextResponse.next()
}This runs on every request by default. Add a matcher to limit it:
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}The Matcher
The matcher controls which requests trigger middleware. It's a glob or array of globs:
export const config = {
matcher: [
// All routes except static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Or specific routes:
export const config = {
matcher: [
'/dashboard/:path*', // dashboard and all sub-routes
'/api/:path*', // all API routes
'/admin/:path*', // admin section
],
}The /((?!pattern).*) negative lookahead syntax is standard — always exclude static assets or your middleware runs for every image and CSS file.
Authentication Guard
The most common middleware pattern: redirect unauthenticated users to login, redirect logged-in users away from the login page.
With a Session Cookie
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC_PATHS = ['/', '/login', '/register', '/about', '/blog']
const AUTH_PATHS = ['/login', '/register']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const sessionToken = request.cookies.get('session-token')?.value
const isPublic = PUBLIC_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`)
)
// Not authenticated, trying to access protected route
if (!sessionToken && !isPublic) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// Already authenticated, trying to access auth pages
if (sessionToken && AUTH_PATHS.includes(pathname)) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
}With JWT Verification
For JWTs you can verify in the Edge Runtime using the jose library (Web Crypto API, no Node.js dependencies):
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!)
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 token = request.cookies.get('auth-token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
const payload = await verifyToken(token)
if (!payload) {
const response = NextResponse.redirect(new URL('/login', request.url))
response.cookies.delete('auth-token')
return response
}
// Pass user info to downstream handlers via headers
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.sub as string)
requestHeaders.set('x-user-role', payload.role as string)
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}Then in any Server Component or API route:
import { headers } from 'next/headers'
export default async function DashboardPage() {
const userId = headers().get('x-user-id')
const role = headers().get('x-user-role')
// ...
}Clerk Integration
If you're using Clerk for auth, middleware is where it lives. Clerk's middleware handles token verification, session management, and route protection automatically.
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
'/blog(.*)',
'/api/webhook(.*)',
])
export default clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect()
}
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)', '/(api|trpc)(.*)'],
}The Clerk + Next.js authentication guide covers the full setup including auth() in Server Components and useAuth() in Client Components.
Role-Based Access Control
Protect routes based on user roles:
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isAdminRoute = createRouteMatcher(['/admin(.*)'])
const isPublicRoute = createRouteMatcher(['/', '/sign-in(.*)', '/sign-up(.*)'])
export default clerkMiddleware(async (auth, request) => {
const { userId, sessionClaims } = await auth()
if (!isPublicRoute(request)) {
if (!userId) {
return auth.redirectToSignIn()
}
if (isAdminRoute(request) && sessionClaims?.metadata?.role !== 'admin') {
return new Response('Forbidden', { status: 403 })
}
}
})Redirects and Rewrites
Conditional Redirect
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Redirect old blog URLs to new format
if (pathname.startsWith('/posts/')) {
const newPath = pathname.replace('/posts/', '/blog/')
return NextResponse.redirect(new URL(newPath, request.url), { status: 301 })
}
// Trailing slash normalization
if (pathname.endsWith('/') && pathname !== '/') {
return NextResponse.redirect(new URL(pathname.slice(0, -1), request.url))
}
return NextResponse.next()
}Transparent Rewrite
Rewrites serve different content at the same URL — the user never sees the internal path:
export function middleware(request: NextRequest) {
// Serve maintenance page without changing the URL
if (process.env.MAINTENANCE_MODE === 'true') {
return NextResponse.rewrite(new URL('/maintenance', request.url))
}
// Proxy /docs to external service (same domain, different backend)
if (request.nextUrl.pathname.startsWith('/docs')) {
const targetUrl = new URL(request.nextUrl.pathname, 'https://docs.internal.company.com')
return NextResponse.rewrite(targetUrl)
}
return NextResponse.next()
}A/B Testing
Middleware is ideal for A/B testing: assign users to buckets at the edge, serve different versions via rewrite, track the bucket in a cookie. No layout shift, no client-side delay:
// middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Only test the landing page
if (pathname !== '/') return NextResponse.next()
// Check for existing bucket assignment
let bucket = request.cookies.get('ab-bucket')?.value
if (!bucket) {
bucket = Math.random() < 0.5 ? 'a' : 'b'
}
// Rewrite to the correct variant
const url = request.nextUrl.clone()
url.pathname = bucket === 'b' ? '/landing-b' : '/'
const response = NextResponse.rewrite(url)
// Persist the bucket assignment for 30 days
if (!request.cookies.get('ab-bucket')) {
response.cookies.set('ab-bucket', bucket, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
})
}
return response
}
export const config = {
matcher: ['/'],
}In your analytics: log the ab-bucket cookie alongside conversion events.
Internationalization (i18n)
Route users to the correct locale based on their browser preferences:
// middleware.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const LOCALES = ['en', 'es', 'fr', 'de', 'pt']
const DEFAULT_LOCALE = 'en'
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => { negotiatorHeaders[key] = value })
const languages = new Negotiator({ headers: negotiatorHeaders }).languages()
try {
return match(languages, LOCALES, DEFAULT_LOCALE)
} catch {
return DEFAULT_LOCALE
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Check if pathname already has a locale
const hasLocale = LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (!hasLocale) {
const locale = getLocale(request)
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
}With this, /about automatically redirects to /en/about, /es/about, etc., based on the Accept-Language header.
Security Headers
Add security headers to every response without touching individual routes:
// middleware.ts
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY')
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff')
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
// Remove server header
response.headers.delete('Server')
// Content Security Policy (adjust for your needs)
response.headers.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://va.vercel-scripts.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.stacknotice.com",
].join('; ')
)
return response
}Rate Limiting
Basic rate limiting in the Edge Runtime using Vercel's KV (or Upstash Redis via REST API):
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
const MAX_REQUESTS = 20
// Using Upstash Redis REST API (works in Edge Runtime)
async function isRateLimited(ip: string): Promise<boolean> {
const key = `rate_limit:${ip}`
const now = Date.now()
const windowStart = now - RATE_LIMIT_WINDOW
const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/pipeline`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([
['zadd', key, now, now.toString()],
['zremrangebyscore', key, 0, windowStart],
['zcard', key],
['expire', key, 60],
]),
})
const data = await res.json()
const count = data[2][1]
return count > MAX_REQUESTS
}
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
if (await isRateLimited(ip)) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' },
})
}
}
return NextResponse.next()
}For simpler cases, @upstash/ratelimit provides a clean API wrapper.
Geolocation-Based Routing (Vercel)
On Vercel, request.geo provides country, region, and city:
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? 'US'
const { pathname } = request.nextUrl
// Redirect EU users to GDPR-compliant version
const euCountries = ['DE', 'FR', 'IT', 'ES', 'PL', 'NL', 'BE', 'SE', 'AT', 'CH']
if (euCountries.includes(country) && !pathname.startsWith('/eu')) {
return NextResponse.redirect(new URL(`/eu${pathname}`, request.url))
}
// Block access from certain regions
if (country === 'BLOCKED_COUNTRY') {
return new NextResponse('Service not available in your region', { status: 451 })
}
// Pass country to downstream via header
const response = NextResponse.next()
response.headers.set('x-user-country', country)
return response
}Combining Multiple Concerns
Real production middleware often handles several things. Keep it readable:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
function addSecurityHeaders(response: NextResponse): NextResponse {
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
function handleOldUrls(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl
if (pathname.startsWith('/posts/')) {
return NextResponse.redirect(new URL(pathname.replace('/posts/', '/blog/'), request.url), 301)
}
return null
}
function checkMaintenance(request: NextRequest): NextResponse | null {
if (process.env.MAINTENANCE === 'true' && !request.nextUrl.pathname.startsWith('/maintenance')) {
return NextResponse.rewrite(new URL('/maintenance', request.url))
}
return null
}
export function middleware(request: NextRequest) {
// Check each concern in order, return early if handled
const redirect = handleOldUrls(request)
if (redirect) return addSecurityHeaders(redirect)
const maintenance = checkMaintenance(request)
if (maintenance) return addSecurityHeaders(maintenance)
return addSecurityHeaders(NextResponse.next())
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Debugging Middleware
Middleware runs on the server — check the terminal, not the browser console:
export function middleware(request: NextRequest) {
console.log('[middleware]', request.method, request.nextUrl.pathname)
const token = request.cookies.get('auth-token')?.value
console.log('[middleware] has token:', !!token)
return NextResponse.next()
}In development, this logs to your terminal. On Vercel, these appear in Function Logs in the dashboard.
Performance Considerations
Middleware adds latency. Keep it fast:
- Exit early — check the matcher first, return
NextResponse.next()as soon as possible for routes that don't need processing - Avoid heavy computation — no crypto, no large JSON parsing
- Minimize external calls — each async call adds latency; batch or cache when possible
- Use the matcher — running middleware on static assets is wasted work
Typical middleware overhead on Vercel Edge: 0.5–2ms. With an external Redis call: 5–15ms depending on region.
The middleware file must be at the root of the project, not inside app/. Node.js APIs don't work in Edge Runtime. And a matcher that catches static assets runs middleware on every image and font request — expect slow page loads.
Common Mistakes
Wrong: Putting the middleware file in app/
app/middleware.ts ← doesn't work
middleware.ts ← correct (root of project)
Wrong: Using Node.js APIs
import { readFileSync } from 'fs' // ❌ not available in Edge Runtime
import jwt from 'jsonwebtoken' // ❌ uses Node.js cryptoRight: Use Web Crypto alternatives
import { jwtVerify } from 'jose' // ✅ Web Crypto APIWrong: Blocking matcher catching static assets
// This runs middleware for every PNG, CSS, and font file
matcher: ['/:path*']Right:
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)']Quick Reference
| Use Case | Mechanism |
|---|---|
| Auth redirect | NextResponse.redirect() |
| Transparent rewrite | NextResponse.rewrite() |
| Block request | new NextResponse('Forbidden', { status: 403 }) |
| Add/modify headers | response.headers.set() |
| Set cookie | response.cookies.set() |
| Read cookie | request.cookies.get() |
| Get IP | request.headers.get('x-forwarded-for') |
| Get country (Vercel) | request.geo?.country |
Conclusion
Middleware sits at the best possible place in the request lifecycle: before everything, fast, and global. Once you internalize what it can and can't do (Edge Runtime, no Node.js APIs, no database), it becomes an essential tool.
The most impactful patterns are auth protection (redirect unauthenticated users), security headers (zero-cost security improvement on every response), and URL redirects (permanent 301s without a full page render).
If you're adding auth to your Next.js app, start with the Clerk authentication guide — it handles the middleware setup for you. If you're building the API layer, the App Router complete guide covers Route Handlers, Server Components, and how middleware fits into the full request lifecycle.