Next.js Middleware runs before a request is completed, allowing you to modify the response by rewriting, redirecting, modifying headers, or returning directly. It executes on the Edge Runtime, making it incredibly fast for tasks like authentication checks, geo-based redirects, A/B testing, and rate limiting. This guide covers everything you need to know about Next.js middleware, from basic setup to advanced production patterns.
What Is Next.js Middleware?
Middleware is a function that runs before the request reaches your page or API route. It sits between the incoming request and your application, giving you the ability to inspect, modify, or short-circuit requests. Middleware runs on the Edge Runtime, meaning it executes at the CDN edge closest to the user for minimal latency.
// Request flow in Next.js
//
// Client Request
// β
// βΌ
// βββββββββββββββ
// β Middleware β β Runs here (Edge Runtime)
// βββββββββββββββ
// β
// βΌ
// βββββββββββββββ
// β Page/API β β Your application code
// βββββββββββββββ
// β
// βΌ
// Client ResponseBasic Middleware Setup
Create a middleware.ts (or middleware.js) file in the root of your project (at the same level as pages or app directory). The middleware function receives a NextRequest object and must return a NextResponse.
// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Log every request
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
// Continue to the next handler
return NextResponse.next();
}
// Only run on specific paths
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Configuring the Matcher
The matcher config determines which routes trigger the middleware. Without a matcher, middleware runs on every request including static files and images, which you usually want to avoid for performance.
// Match specific routes
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};
// Match all routes except static files and API
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
// Multiple matchers with different patterns
export const config = {
matcher: [
'/dashboard/:path*', // All dashboard routes
'/admin/:path*', // All admin routes
'/api/protected/:path*', // Protected API routes
'/profile', // Exact match
],
};
// Conditional matching with has/missing (Next.js 13.1+)
export const config = {
matcher: [
{
source: '/api/:path*',
has: [
{ type: 'header', key: 'authorization' },
],
},
],
};Authentication Middleware
One of the most common uses of middleware is protecting routes that require authentication. You can check for session tokens, JWTs, or cookies and redirect unauthenticated users to a login page.
JWT Token Verification
For JWT-based authentication, you can verify tokens directly in middleware without hitting your API server. This provides fast, edge-level authentication.
// middleware.ts β JWT Authentication
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_PATHS = ['/login', '/register', '/forgot-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip public paths
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// Get token from cookie or Authorization header
const token =
request.cookies.get('auth-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// Redirect to login with return URL
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
try {
// Verify JWT token
const { payload } = await jwtVerify(token, JWT_SECRET);
// Add user info to headers for downstream use
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 (error) {
// Token expired or invalid β redirect to login
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
const response = NextResponse.redirect(loginUrl);
response.cookies.delete('auth-token');
return response;
}
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/api/protected/:path*'],
};Role-Based Access Control
Middleware can enforce role-based access control by checking user roles from the token or session before allowing access to specific routes.
// Role-based access control in middleware
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/admin': ['admin'],
'/dashboard': ['admin', 'editor', 'viewer'],
'/editor': ['admin', 'editor'],
'/api/admin': ['admin'],
};
function hasPermission(pathname: string, role: string): boolean {
for (const [route, roles] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(route)) {
return roles.includes(role);
}
}
return true; // No restriction for unmatched routes
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', request.url));
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
const role = payload.role as string;
const { pathname } = request.nextUrl;
if (!hasPermission(pathname, role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}Redirect Patterns
Middleware is ideal for handling redirects based on various conditions like locale, geography, device type, or custom business rules.
Geo-Based Redirects
Next.js provides geolocation data in the request object on Vercel. Use this to redirect users to region-specific content or comply with regional regulations.
// Geo-based redirect (Vercel Edge)
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Redirect EU users to GDPR-compliant page
const EU_COUNTRIES = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI',
'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU',
'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
];
if (EU_COUNTRIES.includes(country) &&
request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/eu', request.url));
}
// Add geo headers for downstream use
const response = NextResponse.next();
response.headers.set('x-country', country);
response.headers.set('x-city', city);
return response;
}Locale Detection and Redirect
Automatically detect the user preferred language from the Accept-Language header and redirect to the appropriate locale route.
// Locale detection middleware
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es', 'ja', 'ko', 'zh'];
const DEFAULT_LOCALE = 'en';
function getPreferredLocale(request: NextRequest): string {
// Check cookie first (user preference)
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// Parse Accept-Language header
const acceptLang = request.headers.get('accept-language') || '';
const preferred = acceptLang
.split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2))
.find(lang => SUPPORTED_LOCALES.includes(lang));
return preferred || DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if locale is already in the path
const hasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocale) return NextResponse.next();
// Redirect to locale-prefixed path
const locale = getPreferredLocale(request);
const url = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(url);
}Rate Limiting
Implement rate limiting at the edge to protect your API routes from abuse. While edge-based rate limiting has limitations compared to server-side solutions, it provides a fast first line of defense.
// Simple rate limiting with in-memory store
// Note: This works per-edge-node; use Redis for distributed rate limiting
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT = 100; // requests
const RATE_WINDOW = 60 * 1000; // per minute
function isRateLimited(ip: string): boolean {
const now = Date.now();
const record = rateLimit.get(ip);
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + RATE_WINDOW });
return false;
}
record.count++;
return record.count > RATE_LIMIT;
}
export function middleware(request: NextRequest) {
// Only rate-limit API routes
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.ip ||
'127.0.0.1';
if (isRateLimited(ip)) {
return new NextResponse(
JSON.stringify({ error: 'Too many requests' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60',
},
}
);
}
return NextResponse.next();
}Sliding Window Rate Limiter
A more sophisticated approach uses a sliding window algorithm to smooth out rate limiting across time windows.
// Rate limiting with Upstash Redis (production-ready)
// npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
prefix: 'api-ratelimit',
});
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.ip ?? '127.0.0.1';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
const response = success
? NextResponse.next()
: new NextResponse(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
// Always set rate limit headers
response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', reset.toString());
return response;
}Response Headers and Security
Middleware is an excellent place to set security headers, CORS headers, and other response modifications that should apply across your application.
Security Headers
// Security headers middleware
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Prevent XSS attacks
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
);
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Enable HSTS
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}CORS Headers
Handle Cross-Origin Resource Sharing at the middleware level for consistent CORS policy across all API routes.
// CORS middleware for API routes
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://staging.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const origin = request.headers.get('origin') || '';
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// Handle preflight OPTIONS request
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
const response = NextResponse.next();
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
return response;
}A/B Testing with Middleware
Middleware enables server-side A/B testing without client-side flickering. Assign users to test groups using cookies and rewrite to different page variants.
// A/B Testing middleware
export function middleware(request: NextRequest) {
// Only A/B test the homepage
if (request.nextUrl.pathname !== '/') {
return NextResponse.next();
}
// Check if user already has a variant assigned
const variant = request.cookies.get('ab-variant')?.value;
if (variant) {
// Rewrite to the variant page without changing URL
return NextResponse.rewrite(
new URL(`/variants/${variant}`, request.url)
);
}
// Assign a random variant (50/50 split)
const newVariant = Math.random() < 0.5 ? 'control' : 'experiment';
const response = NextResponse.rewrite(
new URL(`/variants/${newVariant}`, request.url)
);
// Persist variant in cookie for 30 days
response.cookies.set('ab-variant', newVariant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
});
return response;
}URL Rewriting
Rewrite URLs without changing what the user sees in the browser. This is useful for proxying requests, multitenancy, and clean URL structures.
// Multi-tenant rewriting
// app.mysite.com β /tenants/app
// docs.mysite.com β /tenants/docs
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'mysite') {
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
);
}
return NextResponse.next();
}
// Proxy API requests to external service
// /api/external/* β https://api.external-service.com/*
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/external/')) {
const path = request.nextUrl.pathname.replace('/api/external', '');
return NextResponse.rewrite(
new URL(`https://api.external-service.com${path}`)
);
}
return NextResponse.next();
}Chaining Multiple Middleware
Since Next.js only supports a single middleware file, you can chain multiple middleware functions by composing them.
// Composing multiple middleware functions
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareFn = (
request: NextRequest,
response: NextResponse
) => NextResponse | Promise<NextResponse>;
function chain(
functions: MiddlewareFn[],
request: NextRequest
): Promise<NextResponse> {
return functions.reduce(
async (promise, fn) => {
const response = await promise;
return fn(request, response);
},
Promise.resolve(NextResponse.next())
);
}
// Individual middleware functions
const withLogging: MiddlewareFn = (request, response) => {
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
return response;
};
const withSecurityHeaders: MiddlewareFn = (request, response) => {
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
};
const withAuth: MiddlewareFn = (request, response) => {
const token = request.cookies.get('auth-token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
};
// Compose all middleware
export function middleware(request: NextRequest) {
return chain([withLogging, withSecurityHeaders, withAuth], request);
}Edge Runtime Limitations
Middleware runs on the Edge Runtime, which has some limitations compared to Node.js. Understanding these constraints is important for writing effective middleware.
- No access to the filesystem (fs module)
- No native Node.js modules (child_process, crypto with some APIs)
- Limited to Web APIs (fetch, Request, Response, Headers, URL)
- Bundle size limit of 1MB (on Vercel)
- No database connections (use fetch to call API routes instead)
- Limited execution time (typically 25ms on Vercel, configurable)
// What you CAN use in Edge Runtime:
// β
fetch, Request, Response, Headers, URL
// β
TextEncoder, TextDecoder
// β
crypto.subtle (Web Crypto API)
// β
structuredClone
// β
atob, btoa
// β
URLPattern
// β
EdgeDB, Upstash, PlanetScale drivers
// What you CANNOT use:
// β fs (file system)
// β path, os, child_process
// β pg, mysql2, mongoose (traditional DB drivers)
// β bcrypt (use bcryptjs or Web Crypto instead)
// β Large npm packages that exceed 1MB bundleDebugging Middleware
Debugging middleware can be tricky since it runs in the Edge Runtime. Use these techniques to inspect and troubleshoot your middleware.
// Debugging middleware
export function middleware(request: NextRequest) {
// Log request details
console.log({
method: request.method,
url: request.url,
pathname: request.nextUrl.pathname,
cookies: Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value])),
headers: Object.fromEntries(request.headers),
geo: request.geo,
ip: request.ip,
});
// Return debug info in response headers (dev only)
const response = NextResponse.next();
if (process.env.NODE_ENV === 'development') {
response.headers.set('x-middleware-pathname', request.nextUrl.pathname);
response.headers.set('x-middleware-matched', 'true');
}
return response;
}Best Practices
- Keep middleware lightweight β it runs on every matched request.
- Use the matcher config to exclude static files and images.
- Avoid database calls in middleware β use fetch to call API routes instead.
- Set appropriate cache headers for redirects.
- Use cookies for A/B test persistence, not query parameters.
- Implement proper error handling β a crashing middleware blocks all requests.
- Test middleware with different request scenarios (authenticated, unauthenticated, different locales).
- Use environment variables for configuration, not hardcoded values.
Conclusion
Next.js Middleware is a powerful tool that runs at the edge, enabling fast authentication checks, intelligent redirects, rate limiting, A/B testing, and security header management. By executing before your application code, middleware provides a centralized place to handle cross-cutting concerns without duplicating logic across pages and API routes. Keep your middleware lean, use the matcher to target specific routes, and leverage the Edge Runtime capabilities for maximum performance. With the patterns covered in this guide, you can build production-ready middleware that handles authentication, geolocation, rate limiting, and more.
FAQ
Does Next.js middleware run on every request?
By default yes, but you should use the matcher config to limit it to specific routes. Without a matcher, middleware runs on all requests including static assets, which hurts performance.
Can I use a database in Next.js middleware?
Not directly. Middleware runs on the Edge Runtime which does not support traditional database connections. Instead, use fetch to call an API route that accesses the database, or use edge-compatible databases like PlanetScale or Upstash.
What is the difference between middleware redirect and rewrite?
A redirect (NextResponse.redirect) changes the URL in the browser and sends a 307/308 status code. A rewrite (NextResponse.rewrite) serves content from a different URL without changing the browser URL β the user does not see the rewrite.
How do I handle errors in middleware?
Wrap your middleware logic in a try-catch block and return an appropriate response on error. An unhandled error in middleware will block all requests to matched routes. Consider returning NextResponse.next() as a fallback to let the request proceed.