Next.js Middleware Patterns for Production Apps
Practical middleware patterns for Next.js including authentication, A/B testing, geolocation routing, bot detection, and request logging.
What you'll learn
- ✓How Next.js middleware runs at the edge before every matched request
- ✓Authentication and authorization patterns
- ✓A/B testing and feature flags with cookies
- ✓Rate limiting, bot detection, and request logging
Prerequisites
None — this post is self-contained.
Next.js middleware runs at the edge before a request reaches your pages or API routes. It can rewrite URLs, redirect users, set headers, and modify cookies without spinning up a full server. This makes it ideal for cross-cutting concerns that need to execute on every request with minimal latency. This article covers production-ready patterns beyond the basics.
How Middleware Works
Middleware is defined in a single middleware.ts file at the root of your project (or inside src/). It runs on every request that matches the configured matcher.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } 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|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
The matcher uses a path pattern to control which routes trigger the middleware. Without a matcher, middleware runs on every request, including static assets, which wastes edge compute.
Authentication Guard
The most common middleware pattern is redirecting unauthenticated users to a login page:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/settings', '/account'];
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionToken = request.cookies.get('session-token')?.value;
const isProtected = protectedRoutes.some((route) =>
pathname.startsWith(route)
);
const isAuthRoute = authRoutes.some((route) =>
pathname.startsWith(route)
);
// Redirect unauthenticated users to login
if (isProtected && !sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect authenticated users away from auth pages
if (isAuthRoute && sessionToken) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/account/:path*', '/login', '/register'],
};
Role-Based Access Control
Extend the auth pattern with role checking by reading a JWT or session cookie:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const roleRoutes: Record<string, string[]> = {
'/admin': ['admin'],
'/moderator': ['admin', 'moderator'],
'/dashboard': ['admin', 'moderator', 'user'],
};
async function getUserRole(token: string): Promise<string | null> {
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);
return payload.role as string;
} catch {
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('auth-token')?.value;
for (const [route, allowedRoles] of Object.entries(roleRoutes)) {
if (pathname.startsWith(route)) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const role = await getUserRole(token);
if (!role || !allowedRoles.includes(role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
}
return NextResponse.next();
}
A/B Testing with Cookies
Middleware can assign users to experiment groups and rewrite to variant pages without client-side JavaScript:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const EXPERIMENT_COOKIE = 'ab-pricing-page';
const VARIANTS = ['control', 'variant-a', 'variant-b'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/pricing') {
let variant = request.cookies.get(EXPERIMENT_COOKIE)?.value;
// Assign a variant if the user does not have one
if (!variant || !VARIANTS.includes(variant)) {
variant = VARIANTS[Math.floor(Math.random() * VARIANTS.length)];
}
// Rewrite to the variant page
const url = request.nextUrl.clone();
url.pathname = `/pricing/${variant}`;
const response = NextResponse.rewrite(url);
// Persist the assignment for 30 days
response.cookies.set(EXPERIMENT_COOKIE, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
});
return response;
}
return NextResponse.next();
}
The user always sees /pricing in their browser, but the server delivers different content based on their assigned variant. Analytics can read the cookie to track conversion rates.
Geolocation-Based Routing
Vercel and other edge platforms inject geolocation data into the request. Use it for locale detection or content customization:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const COUNTRY_LOCALES: Record<string, string> = {
DE: 'de',
FR: 'fr',
ES: 'es',
JP: 'ja',
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip if already on a locale path
if (/^\/(de|fr|es|ja)\//.test(pathname)) {
return NextResponse.next();
}
// Skip non-page requests
if (pathname.startsWith('/api') || pathname.startsWith('/_next')) {
return NextResponse.next();
}
const country = request.geo?.country || '';
const locale = COUNTRY_LOCALES[country];
if (locale) {
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url);
}
return NextResponse.next();
}
Rate Limiting
Edge-based rate limiting provides basic protection without a separate service. This example uses a simple in-memory approach suitable for single-region deployments:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;
function isRateLimited(identifier: string): boolean {
const now = Date.now();
const entry = rateLimit.get(identifier);
if (!entry || now > entry.resetTime) {
rateLimit.set(identifier, { count: 1, resetTime: now + WINDOW_MS });
return false;
}
entry.count++;
return entry.count > MAX_REQUESTS;
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown';
if (isRateLimited(ip)) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { 'Retry-After': '60' },
}
);
}
}
return NextResponse.next();
}
For production deployments across multiple regions, use an external store like Upstash Redis instead of in-memory maps.
Bot Detection and Blocking
Block known bad bots or serve different content to crawlers:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const BLOCKED_BOTS = ['BadBot', 'ScraperX', 'SpamCrawler'];
const SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot'];
export function middleware(request: NextRequest) {
const userAgent = request.headers.get('user-agent') || '';
// Block malicious bots
if (BLOCKED_BOTS.some((bot) => userAgent.includes(bot))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Add header for search engine bots (useful for conditional rendering)
if (SEARCH_BOTS.some((bot) => userAgent.includes(bot))) {
const response = NextResponse.next();
response.headers.set('x-is-crawler', 'true');
return response;
}
return NextResponse.next();
}
Request Logging and Tracing
Add request IDs and timing headers for observability:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const requestId = crypto.randomUUID();
const startTime = Date.now();
// Add request ID to downstream services
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', requestId);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// Add response headers for debugging
response.headers.set('x-request-id', requestId);
response.headers.set('x-response-time', `${Date.now() - startTime}ms`);
return response;
}
Composing Multiple Patterns
When you need multiple middleware behaviors, compose them in a single function since Next.js only supports one middleware file:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareHandler = (
request: NextRequest
) => NextResponse | undefined;
const authMiddleware: MiddlewareHandler = (request) => {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/dashboard') && !request.cookies.has('session')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return undefined;
};
const loggingMiddleware: MiddlewareHandler = (request) => {
// Log to external service (non-blocking)
console.log(`${request.method} ${request.nextUrl.pathname}`);
return undefined;
};
export function middleware(request: NextRequest) {
const handlers = [loggingMiddleware, authMiddleware];
for (const handler of handlers) {
const result = handler(request);
if (result) return result;
}
return NextResponse.next();
}
Wrapping Up
Next.js middleware gives you a single interception point for cross-cutting concerns at the edge. Use it for authentication guards, A/B testing, geolocation routing, rate limiting, and request tracing. Keep middleware logic fast since it runs on every matched request. For heavy computation or database queries, defer to server components or API routes. The key constraint to remember is that middleware runs in the Edge Runtime, which means no Node.js APIs like fs or native modules. Design your middleware to make fast decisions based on cookies, headers, and URL patterns.
Related articles
- Next.js Authentication Middleware Patterns in Next.js
Implement authentication middleware in Next.js to protect routes, redirect unauthenticated users, and manage JWT or session-based auth at the edge.
- Next.js Next.js Middleware and the Edge Runtime
What Next.js middleware actually is, how it runs on the Edge, what you can and cannot do there, and the patterns that work in production.
- Next.js Next.js Middleware: A Complete Guide
Everything you need to know about Next.js middleware: how it works, matchers, redirects, rewrites, authentication checks, geolocation, rate limiting, and common patterns for production apps.
- Next.js Next.js Edge Config Tutorial: Ultra-Low-Latency Reads
Use Vercel Edge Config to serve feature flags, redirects, and small config blobs from the edge with single-digit-millisecond reads — when to use it, how it differs from KV, and a working example.