Introduction
Next.js Middleware represents a paradigm shift in how web applications handle routing and request processing. Unlike traditional server-side middleware that runs on your origin server, Next.js Middleware executes at the network edge — on CDN nodes closest to your users — before the request even reaches your application code. This architectural decision enables sub-millisecond routing decisions, geographic personalization, and robust authentication flows without the latency penalty of a round-trip to your origin.
Introduced in Next.js 12 and refined through Next.js 13 and 14, middleware gives you access to the incoming Request object and allows you to modify the response through URL rewrites, redirects, header manipulation, and cookie management. The middleware function runs on every request that matches your configured route matcher, making it ideal for cross-cutting concerns like authentication, A/B testing, feature flags, and geolocation-based routing.
This guide covers the architecture, implementation patterns, and production best practices for Next.js Middleware, with a focus on edge computing principles and real-world deployment scenarios.
Understanding Next.js Middleware: Core Concepts
The Edge Runtime
Next.js Middleware runs in the Edge Runtime, a lightweight JavaScript execution environment that mirrors the Web API standard. Unlike the Node.js runtime used by API routes and server components, the Edge Runtime has a smaller API surface: no fs module, no process.env access (only process.env variables explicitly exposed), and a 1MB size limit for the middleware bundle.
This constraint is intentional. The Edge Runtime is designed for fast, stateless operations that don't require heavy computation or file system access. The reduced footprint allows middleware to be deployed to hundreds of edge locations worldwide, executing close to users regardless of their geographic location.
// middleware.ts — runs at the edge on every matched request
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Access the URL
const url = request.nextUrl;
// Access cookies
const token = request.cookies.get('auth-token')?.value;
// Access headers
const country = request.geo?.country || 'US';
// Return NextResponse to continue, rewrite, or redirect
return NextResponse.next();
}
// Configure which routes this middleware applies to
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Request Lifecycle with Middleware
Understanding where middleware fits in the Next.js request lifecycle is crucial:
- Client Request: Browser sends request to the nearest CDN edge node
- Middleware Execution: Your middleware function runs at the edge
- Routing Decision: Middleware can rewrite, redirect, or pass through
- Page/API Execution: The matched route handler runs (SSR, SSG, or API)
- Response: The response flows back through the edge to the client
This lifecycle means middleware can prevent invalid requests from ever reaching your application server, saving compute resources and reducing latency for rejected requests.
The Matcher Configuration
The config.matcher property determines which routes your middleware applies to. Without it, middleware runs on every request, which can impact performance. Next.js uses a path-to-regexp syntax for matching:
export const config = {
matcher: [
// Match specific paths
'/dashboard/:path*',
'/settings/:path*',
// Match all paths except static files and images
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};The negative lookahead pattern in the second matcher is particularly useful — it applies middleware to all dynamic routes while excluding static assets that don't need middleware processing.
Architecture and Design Patterns
Pattern 1: Authentication and Authorization
Middleware is the ideal place to implement authentication checks because it runs before any page or API route. This pattern redirects unauthenticated users to the login page and attaches user context to the request.
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from './lib/auth-edge';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session-token')?.value;
const { pathname } = request.nextUrl;
// Public routes that don't require authentication
const publicPaths = ['/login', '/register', '/forgot-password', '/api/auth'];
if (publicPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// Verify the session token
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
try {
const payload = await verifyToken(token);
// Role-based access control
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// Attach user info to headers for downstream use
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId);
requestHeaders.set('x-user-role', payload.role);
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch {
// Token is invalid — clear it and redirect to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('session-token');
return response;
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|login|register|api/auth).*)'],
};Pattern 2: A/B Testing and Feature Flags
Middleware enables server-side A/B testing without any client-side JavaScript. Users are assigned to test groups via cookies, ensuring consistent experiences across page loads.
import { NextRequest, NextResponse } from 'next/server';
const AB_TEST_COOKIE = 'ab-test-group';
function getTestGroup(userId: string): 'control' | 'variant-a' | 'variant-b' {
// Deterministic assignment based on user ID
const hash = userId.split('').reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
const bucket = hash % 100;
if (bucket < 33) return 'control';
if (bucket < 66) return 'variant-a';
return 'variant-b';
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only apply to the pricing page
if (pathname !== '/pricing') {
return NextResponse.next();
}
let testGroup = request.cookies.get(AB_TEST_COOKIE)?.value;
if (!testGroup) {
// Assign new visitors to a test group
const userId = request.cookies.get('visitor-id')?.value ||
crypto.randomUUID();
testGroup = getTestGroup(userId);
}
// Rewrite to the variant page
const response = NextResponse.rewrite(
new URL(`/pricing/${testGroup}`, request.url)
);
response.cookies.set(AB_TEST_COOKIE, testGroup, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
secure: true,
sameSite: 'lax',
});
return response;
}Pattern 3: Geolocation-Based Routing
Edge middleware has access to the user's geographic location through request.geo. This enables locale-based routing, content personalization, and compliance with regional regulations.
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'ja', 'zh'];
const DEFAULT_LOCALE = 'en';
function getLocaleFromCountry(country: string): string {
const countryToLocale: Record<string, string> = {
US: 'en', GB: 'en', AU: 'en', CA: 'en',
ES: 'es', MX: 'es', AR: 'es',
FR: 'fr', DE: 'de',
JP: 'ja', CN: 'zh', TW: 'zh',
};
return countryToLocale[country] || DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip if locale already in URL
const pathnameHasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Check for preferred locale in cookie
const cookieLocale = request.cookies.get('preferred-locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return NextResponse.redirect(
new URL(`/${cookieLocale}${pathname}`, request.url)
);
}
// Fall back to geo-based locale
const country = request.geo?.country || 'US';
const locale = getLocaleFromCountry(country);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}Step-by-Step Implementation
Step 1: Create the Middleware File
Middleware in Next.js is defined in a middleware.ts (or middleware.js) file at the root of your project — not inside the app or pages directory.
// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Your middleware logic here
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};Step 2: Implement Request Logging and Monitoring
Add structured logging to your middleware for observability:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const start = Date.now();
const { pathname, search } = request.nextUrl;
const method = request.method;
const userAgent = request.headers.get('user-agent') || 'unknown';
const country = request.geo?.country || 'unknown';
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const response = NextResponse.next();
// Add timing header
const duration = Date.now() - start;
response.headers.set('x-middleware-duration', `${duration}ms`);
// Add request ID for tracing
const requestId = crypto.randomUUID();
response.headers.set('x-request-id', requestId);
// Log to edge-compatible observability (e.g., Vercel Analytics)
console.log(JSON.stringify({
type: 'middleware',
requestId,
method,
pathname,
search,
duration,
country,
ip,
userAgent: userAgent.substring(0, 100),
}));
return response;
}Step 3: Implement Security Headers
Add security headers at the middleware level so they apply to all responses:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// CSP header (adjust based on your needs)
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
].join('; ');
response.headers.set('Content-Security-Policy', csp);
return response;
}Step 4: Handle Bot Detection and Rate Limiting
import { NextRequest, NextResponse } from 'next/server';
// Simple in-memory rate limiter (use Redis in production)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const limit = rateLimitMap.get(ip);
if (!limit || now > limit.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + 60000 }); // 1 minute window
return false;
}
limit.count++;
return limit.count > 100; // 100 requests per minute
}
export function middleware(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
// Rate limit API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
if (isRateLimited(ip)) {
return new NextResponse(
JSON.stringify({ error: 'Too many requests' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Block known bad bots
const userAgent = request.headers.get('user-agent') || '';
const badBots = ['SemrushBot', 'AhrefsBot', 'MJ12bot'];
if (badBots.some(bot => userAgent.includes(bot))) {
return new NextResponse(null, { status: 403 });
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/:path*', '/((?!_next/static|_next/image|favicon.ico).*)'],
};Real-World Use Cases
Use Case 1: Multi-Tenant SaaS Routing
SaaS applications that serve multiple tenants from a single codebase can use middleware to identify the tenant from the subdomain and rewrite the request to the appropriate tenant's pages.
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const parts = hostname.split('.');
if (parts.length >= 3) {
const tenant = parts[0]; // e.g., "acme" from "acme.app.com"
const url = request.nextUrl.clone();
url.pathname = `/tenant/${tenant}${url.pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}Use Case 2: Maintenance Mode
Deploy a maintenance page without redeploying your application by toggling a feature flag in middleware:
export function middleware(request: NextRequest) {
const isMaintenanceMode = process.env.MAINTENANCE_MODE === 'true';
if (isMaintenanceMode) {
// Allow access to status page and admin
if (request.nextUrl.pathname.startsWith('/status') ||
request.nextUrl.pathname.startsWith('/admin')) {
return NextResponse.next();
}
return NextResponse.rewrite(new URL('/maintenance', request.url));
}
return NextResponse.next();
}Use Case 3: Request Rewriting for Micro-Frontends
Middleware can route requests to different micro-frontend deployments based on URL patterns:
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Route /shop/* to the shop micro-frontend
if (pathname.startsWith('/shop')) {
const url = request.nextUrl.clone();
url.pathname = pathname.replace('/shop', '');
return NextResponse.rewrite(new URL(url.toString(), 'https://shop.example.com'));
}
// Route /blog/* to the blog micro-frontend
if (pathname.startsWith('/blog')) {
const url = request.nextUrl.clone();
url.pathname = pathname.replace('/blog', '');
return NextResponse.rewrite(new URL(url.toString(), 'https://blog.example.com'));
}
return NextResponse.next();
}Best Practices for Production
-
Keep Middleware Lightweight: The Edge Runtime has a 1MB bundle size limit. Avoid importing heavy libraries. Use edge-compatible packages and tree-shake aggressively.
-
Use Specific Matchers: Don't run middleware on static assets. Configure your
matcherto exclude_next/static,_next/image, andfavicon.icopaths. -
Handle Errors Gracefully: Middleware runs on every request — unhandled errors can take down your entire site. Wrap your logic in try-catch and return a fallback response.
-
Minimize External Calls: Every external API call in middleware adds latency. Cache responses at the edge or use stale-while-revalidate patterns.
-
Test with
next build && next start: Middleware behaves differently in development mode. Always test with a production build to verify edge runtime behavior. -
Use
NextResponse.rewrite()for Internal Routes: Rewrites are invisible to the user and maintain the original URL in the browser. Redirects change the URL and add a round-trip. -
Implement Circuit Breakers for External Services: If your middleware depends on external services (auth, feature flags), implement circuit breakers to prevent cascading failures.
-
Monitor Middleware Performance: Track execution time at the edge. Middleware that takes too long degrades the user experience. Aim for under 10ms execution time.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Importing Node.js APIs | Build failures at the edge | Use Web API equivalents or edge-compatible packages |
| Running middleware on all paths | Unnecessary overhead on static assets | Configure specific matcher patterns |
| Heavy computation in middleware | Increased latency for all requests | Move heavy logic to API routes or server components |
| Not handling edge runtime limits | 1MB bundle size exceeded | Use dynamic imports and tree-shaking |
Accessing process.env directly | Undefined variables at the edge | Use process.env.VAR_NAME only for explicitly exposed variables |
| Synchronous crypto operations | Not available in edge runtime | Use Web Crypto API (crypto.subtle) |
Performance Optimization
// Pre-computed lookup tables for fast matching
const MAINTENANCE_PATHS = new Set(['/maintenance', '/status']);
const PUBLIC_PATHS = new Set(['/login', '/register', '/api/health']);
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Fast path: skip middleware for known public paths
if (PUBLIC_PATHS.has(pathname) || MAINTENANCE_PATHS.has(pathname)) {
return NextResponse.next();
}
// Use startsWith instead of regex for better performance
if (pathname.startsWith('/_next/') || pathname.startsWith('/static/')) {
return NextResponse.next();
}
// Your middleware logic here
return NextResponse.next();
}Comparison with Alternatives
| Feature | Next.js Middleware | Express Middleware | Edge Functions | Server Components |
|---|---|---|---|---|
| Execution Location | Edge (CDN) | Origin server | Edge (CDN) | Origin server |
| Latency | Sub-millisecond | 50-200ms | Sub-millisecond | 50-200ms |
| Bundle Size Limit | 1MB | No limit | 1-10MB | No limit |
| Node.js APIs | Limited | Full | Limited | Full |
| Use Case | Routing, auth | Full backend | API endpoints | Data fetching |
| Cost Model | Per request | Per server | Per request | Per server |
Advanced Patterns
Chaining Middleware with Next.js Rewrites
You can create middleware chains by using rewrite targets that trigger additional middleware:
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// First middleware: authentication
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Second middleware: tenant resolution
if (pathname.startsWith('/app')) {
const tenant = resolveTenant(request.headers.get('host'));
const url = request.nextUrl.clone();
url.pathname = `/tenant/${tenant}${pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}Conditional Middleware with Feature Flags
import { NextRequest, NextResponse } from 'next/server';
async function getFeatureFlags(userId: string): Promise<Record<string, boolean>> {
// In production, use an edge-compatible feature flag service
const response = await fetch(`https://flags.example.com/api/flags?user=${userId}`);
return response.json();
}
export async function middleware(request: NextRequest) {
const userId = request.cookies.get('user-id')?.value;
if (userId && request.nextUrl.pathname.startsWith('/new-dashboard')) {
const flags = await getFeatureFlags(userId);
if (!flags.newDashboard) {
// Redirect to old dashboard if feature flag is off
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}Testing Strategies
// middleware.test.ts
import { NextRequest } from 'next/server';
import { middleware } from './middleware';
function createRequest(path: string, cookies?: Record<string, string>) {
const url = new URL(path, 'http://localhost:3000');
const headers = new Headers();
const cookieHeader = cookies
? Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ')
: '';
if (cookieHeader) headers.set('cookie', cookieHeader);
return new NextRequest(url, { headers });
}
describe('Middleware', () => {
it('redirects unauthenticated users to login', () => {
const request = createRequest('/dashboard');
const response = middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/login');
});
it('allows authenticated users to access dashboard', () => {
const request = createRequest('/dashboard', {
'session-token': 'valid-token',
});
const response = middleware(request);
expect(response.status).toBe(200);
});
it('blocks requests exceeding rate limit', () => {
const ip = '192.168.1.1';
const responses = [];
for (let i = 0; i < 101; i++) {
const request = createRequest('/api/data');
responses.push(middleware(request));
}
expect(responses[100].status).toBe(429);
});
});Future Outlook
Next.js Middleware continues to evolve with the broader edge computing ecosystem. The introduction of Partial Prerendering (PPR) combines middleware with streaming SSR, allowing middleware to set up the page shell while dynamic content streams in from the origin. WebAssembly support in the edge runtime will expand the computational capabilities of middleware, enabling complex operations like image processing and cryptographic verification at the edge.
The middleware API is also converging with the broader Web Middleware standard being developed by the WinterCG community, ensuring that skills learned with Next.js Middleware transfer to other edge platforms.
Conclusion
Next.js Middleware transforms how you think about request processing by moving routing decisions to the network edge. Its position in the request lifecycle — before any page or API route executes — makes it the ideal place for cross-cutting concerns like authentication, A/B testing, geolocation routing, and security headers.
Key takeaways:
- Middleware runs at the edge with sub-millisecond latency, ideal for routing and auth decisions
- Use the
matcherconfiguration to avoid running middleware on static assets - Keep middleware lightweight — the Edge Runtime has a 1MB bundle limit and restricted APIs
- Implement authentication, rate limiting, and security headers at the middleware level for consistent protection
- Test with production builds, as middleware behaves differently in development mode
For further learning, consult the Next.js Middleware documentation and explore the Edge Runtime API reference.