Next.js Middleware — Authentication, Routing, and Edge Logic
Advertisement
Introduction
Next.js Middleware runs on the Edge Runtime before a request reaches your route handlers or pages. It intercepts every request that matches the config.matcher pattern and can redirect, rewrite, modify headers, or pass the request through. Because it runs at the edge — close to users, with no cold start — it adds negligible latency while enabling powerful request-level control.
Why This Matters
Without middleware, protecting routes requires checking authentication in every page's server-side code. A single missed check leaves a route exposed. Middleware centralizes this logic: one file, one place, enforced for every request before any page code runs.
Middleware also enables patterns impossible with page-level code: multi-tenant routing based on subdomain, A/B testing via cookie, locale detection and redirect, and rate limiting at the edge. These run in under 1ms because Edge Runtime is globally distributed.
The key constraint: Middleware runs in the Edge Runtime, which supports a subset of Node.js APIs. No file system access, no native modules, and no node: imports. Keep it lightweight.
Creating Middleware
Create middleware.ts in the project root (same level as app/):
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
// Runs before every matched request
return NextResponse.next()
}
export const config = {
matcher: [
// Match all paths except static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}Authentication Middleware with JWT
Verify a JWT token on every protected route:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const PROTECTED_PATHS = ['/dashboard', '/admin', '/api/protected']
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
const token = request.cookies.get('auth-token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
// Admin-only routes
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/denied', request.url))
}
// Pass user info to pages via headers
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub as string)
response.headers.set('x-user-role', payload.role as string)
return response
} catch {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
}Locale Detection and Redirect
Detect user language and redirect to the appropriate locale prefix:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt']
const DEFAULT_LOCALE = 'en'
function detectLocale(request: NextRequest): string {
// 1. Check cookie preference
const cookieLocale = request.cookies.get('locale')?.value
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) return cookieLocale
// 2. Check Accept-Language header
const acceptLang = request.headers.get('accept-language')
if (acceptLang) {
const preferred = acceptLang.split(',')[0].split('-')[0].toLowerCase()
if (SUPPORTED_LOCALES.includes(preferred)) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
const hasLocale = SUPPORTED_LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (!hasLocale) {
const locale = detectLocale(request)
const url = new URL(`/${locale}${pathname}`, request.url)
url.search = request.nextUrl.search
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|public/).*)'],
}Multi-Tenant Routing by Subdomain
Route requests to tenant-specific pages based on subdomain without changing the URL:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') ?? ''
const pathname = request.nextUrl.pathname
// Extract subdomain: app.example.com → 'app'
const subdomain = hostname.split('.')[0]
// Skip root domain and www
if (['localhost', 'example', 'www'].includes(subdomain)) {
return NextResponse.next()
}
// Rewrite to /tenants/[subdomain]/... without changing the browser URL
const url = request.nextUrl.clone()
url.pathname = `/tenants/${subdomain}${pathname}`
return NextResponse.rewrite(url)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Adding Security Headers
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
}
export function middleware(request: NextRequest) {
const response = NextResponse.next()
Object.entries(SECURITY_HEADERS).forEach(([key, value]) => {
response.headers.set(key, value)
})
return response
}Edge Rate Limiting
Simple in-memory rate limiting (use Upstash Redis for production):
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
const LIMIT = 100
const WINDOW_MS = 60_000 // 1 minute
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api')) {
return NextResponse.next()
}
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return NextResponse.next()
}
if (entry.count >= LIMIT) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Try again in 60 seconds.' },
{ status: 429, headers: { 'Retry-After': '60' } }
)
}
entry.count++
return NextResponse.next()
}A/B Testing with Cookies
Assign users to experiment variants at the edge:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const variant = request.cookies.get('ab-variant')?.value
if (!variant) {
const newVariant = Math.random() < 0.5 ? 'a' : 'b'
const response = NextResponse.rewrite(
new URL(`/experiments/${newVariant}${request.nextUrl.pathname}`, request.url)
)
response.cookies.set('ab-variant', newVariant, { maxAge: 60 * 60 * 24 * 30 })
return response
}
return NextResponse.rewrite(
new URL(`/experiments/${variant}${request.nextUrl.pathname}`, request.url)
)
}Common Mistakes
- Doing heavy database queries in middleware — the Edge Runtime is not designed for DB connections
- Forgetting to configure
matcher, so middleware runs on every request including static assets - Using
node:imports — the Edge Runtime does not support Node.js built-ins - Not handling the case where a redirect loop occurs (e.g., redirecting
/loginto/login) - Mutating
requestdirectly instead of usingNextResponse.next()orNextResponse.rewrite()
Best Practices
- Keep middleware fast and lightweight — under 5ms ideally
- Use
config.matcherto narrow scope; avoid running middleware on_next/staticand image paths - Pass data from middleware to pages using
response.headers.set(), then read withheaders()in Server Components - For production rate limiting, use Upstash Redis with the
@upstash/ratelimitpackage - Use
NextResponse.rewrite()to proxy requests without redirecting the browser - Test middleware locally with
next dev— it runs in the same Edge-like environment
Key Takeaways
- Middleware runs on the Edge Runtime before any route handler — no cold starts, global distribution
- It is created in
middleware.tsat the project root and exported as a namedmiddlewarefunction config.matchercontrols which paths trigger middleware using glob patternsNextResponse.redirect()changes the URL;NextResponse.rewrite()proxies without changing the URLNextResponse.next()passes the request through with optional header modifications- The Edge Runtime does not support Node.js built-ins — no
fs, no native modules - Use middleware for auth guards, locale detection, subdomain routing, and security headers
- For database-driven logic, pass a session token in a cookie and verify it with a lightweight JWT check in middleware
Advertisement