Introduction to Middleware in Next.js
Middleware in Next.js allows you to run code before a request is completed. You can modify the response by rewriting, redirecting, adding headers, or setting cookies. Middleware runs before cached content and routes are matched, making it perfect for authentication, localization, A/B testing, and more.
Note: Middleware runs on the Edge Runtime, which means it executes closer to your users for better performance. However, not all Node.js APIs are available in the Edge Runtime, so be mindful of the APIs you use.
Creating Middleware
Middleware is defined in a middleware.ts file at the root of your project (same level as app or pages directory):
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
console.log('Middleware executed for:', request.nextUrl.pathname);
return NextResponse.next();
}
This basic middleware logs every request and continues to the next handler. The middleware function receives a NextRequest object and must return a NextResponse.
Middleware Responses
Middleware can return different types of responses:
NextResponse.next()
Continue the request pipeline:
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add custom headers
response.headers.set('X-Custom-Header', 'MyValue');
return response;
}
NextResponse.redirect()
Redirect to a different URL:
export function middleware(request: NextRequest) {
const isAuthenticated = request.cookies.get('auth-token');
if (!isAuthenticated && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
NextResponse.rewrite()
Internally rewrite to a different URL without changing the browser URL:
export function middleware(request: NextRequest) {
// Show maintenance page without changing URL
if (process.env.MAINTENANCE_MODE === 'true') {
return NextResponse.rewrite(new URL('/maintenance', request.url));
}
return NextResponse.next();
}
NextResponse.json()
Return a JSON response directly from middleware:
export function middleware(request: NextRequest) {
const apiKey = request.headers.get('x-api-key');
if (!apiKey) {
return NextResponse.json(
{ error: 'API key required' },
{ status: 401 }
);
}
return NextResponse.next();
}
Configuring Matchers
By default, middleware runs on every request. Use matchers to specify which paths should trigger middleware:
Basic Matcher Configuration
// middleware.ts
export function middleware(request: NextRequest) {
// Your middleware logic
return NextResponse.next();
}
// Only run on specific paths
export const config = {
matcher: '/dashboard/:path*'
};
Multiple Path Matchers
export const config = {
matcher: [
'/dashboard/:path*',
'/admin/:path*',
'/api/:path*'
]
};
Complex Matcher Patterns
export const config = {
matcher: [
// Match all request paths except for those 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).*)',
]
};
Conditional Matchers
export const config = {
matcher: [
// Match /about with optional trailing slash
'/about/:path?',
// Match /blog followed by any path
'/blog/:path+',
// Match /product followed by at least one segment
'/product/:id+'
]
};
Tip: Use the matcher configuration to limit middleware execution to only the routes that need it. This improves performance by avoiding unnecessary middleware runs on static assets and public routes.
Authentication with Middleware
One of the most common use cases for middleware is protecting routes with authentication:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
// Redirect authenticated users away from auth pages
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Redirect unauthenticated users to login
if (isProtectedRoute && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login']
};
Token Verification
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
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 {
// Verify JWT token
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);
// Add user info to headers for downstream handlers
const response = NextResponse.next();
response.headers.set('X-User-Id', payload.userId as string);
response.headers.set('X-User-Role', payload.role as string);
return response;
} catch (error) {
// Invalid token, redirect to login
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: '/dashboard/:path*'
};
Localization with Middleware
Implement internationalization by detecting and redirecting based on user locale:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const locales = ['en', 'ar', 'fr', 'es'];
const defaultLocale = 'en';
function getLocale(request: NextRequest): string {
// Check URL parameter
const urlLocale = request.nextUrl.pathname.split('/')[1];
if (locales.includes(urlLocale)) {
return urlLocale;
}
// Check cookie
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale;
}
// Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const browserLocale = acceptLanguage.split(',')[0].split('-')[0];
if (locales.includes(browserLocale)) {
return browserLocale;
}
}
return defaultLocale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if pathname already has a locale
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Redirect to locale-prefixed URL
const locale = getLocale(request);
const response = NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
// Set locale cookie for future requests
response.cookies.set('locale', locale, { maxAge: 31536000 }); // 1 year
return response;
}
export const config = {
matcher: [
// Skip all internal paths (_next, api, static files)
'/((?!api|_next/static|_next/image|favicon.ico).*)',
]
};
A/B Testing with Middleware
Implement A/B testing by randomly assigning users to different variants:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Check if user already has a variant assigned
let variant = request.cookies.get('ab-test-variant')?.value;
if (!variant) {
// Randomly assign variant A or B
variant = Math.random() < 0.5 ? 'A' : 'B';
}
const response = variant === 'B'
? NextResponse.rewrite(new URL('/variant-b', request.url))
: NextResponse.next();
// Store variant in cookie for consistency
response.cookies.set('ab-test-variant', variant, {
maxAge: 60 * 60 * 24 * 30 // 30 days
});
// Add variant to headers for analytics
response.headers.set('X-AB-Test-Variant', variant);
return response;
}
export const config = {
matcher: '/landing'
};
Rate Limiting with Middleware
Implement basic rate limiting to prevent abuse:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// In-memory store (use Redis in production)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT = 10; // requests
const WINDOW_MS = 60000; // 1 minute
export function middleware(request: NextRequest) {
const ip = request.ip || 'unknown';
const now = Date.now();
// Get or create rate limit entry
let rateLimit = rateLimitMap.get(ip);
if (!rateLimit || now > rateLimit.resetTime) {
// Create new rate limit window
rateLimit = {
count: 1,
resetTime: now + WINDOW_MS
};
rateLimitMap.set(ip, rateLimit);
} else {
// Increment count
rateLimit.count++;
}
// Check if rate limit exceeded
if (rateLimit.count > RATE_LIMIT) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((rateLimit.resetTime - now) / 1000))
}
}
);
}
// Add rate limit headers
const response = NextResponse.next();
response.headers.set('X-RateLimit-Limit', String(RATE_LIMIT));
response.headers.set('X-RateLimit-Remaining', String(RATE_LIMIT - rateLimit.count));
response.headers.set('X-RateLimit-Reset', String(rateLimit.resetTime));
return response;
}
export const config = {
matcher: '/api/:path*'
};
Warning: This in-memory rate limiting example is suitable for single-instance deployments only. For production applications with multiple instances, use a distributed cache like Redis or Vercel's Edge Config to store rate limit data.
Setting Custom Headers
Add custom headers to responses for security, analytics, or feature flags:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Custom headers
response.headers.set('X-Request-Id', crypto.randomUUID());
response.headers.set('X-Server-Region', process.env.VERCEL_REGION || 'unknown');
// Feature flags
response.headers.set('X-Feature-NewUI', 'true');
return response;
}
Bot Detection and Blocking
Detect and handle bot traffic:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const BOT_USER_AGENTS = [
'bot',
'crawler',
'spider',
'scraper',
'curl',
'wget'
];
export function middleware(request: NextRequest) {
const userAgent = request.headers.get('user-agent')?.toLowerCase() || '';
// Check if user agent matches bot patterns
const isBot = BOT_USER_AGENTS.some(pattern => userAgent.includes(pattern));
if (isBot) {
// You can: block, redirect, or serve different content
// Option 1: Block
return NextResponse.json(
{ error: 'Bot access not allowed' },
{ status: 403 }
);
// Option 2: Serve bot-optimized content
// return NextResponse.rewrite(new URL('/bot-version', request.url));
// Option 3: Add header for downstream handling
// const response = NextResponse.next();
// response.headers.set('X-Is-Bot', 'true');
// return response;
}
return NextResponse.next();
}
Geolocation-based Routing
Route users based on their geographic location:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Vercel provides geo headers automatically
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Redirect based on country
if (country === 'CN') {
return NextResponse.rewrite(new URL('/cn', request.url));
}
if (country === 'JP') {
return NextResponse.rewrite(new URL('/jp', request.url));
}
// Add geo information to headers
const response = NextResponse.next();
response.headers.set('X-User-Country', country);
response.headers.set('X-User-City', city);
return response;
}
Chaining Multiple Middleware Functions
Organize complex middleware logic by chaining multiple handlers:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareFactory = (
middleware: MiddlewareFunction
) => MiddlewareFunction;
type MiddlewareFunction = (
request: NextRequest
) => NextResponse | Promise<NextResponse>;
// Individual middleware functions
function withAuth(middleware: MiddlewareFunction): MiddlewareFunction {
return async (request) => {
const token = request.cookies.get('auth-token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return middleware(request);
};
}
function withLogging(middleware: MiddlewareFunction): MiddlewareFunction {
return async (request) => {
console.log('Request:', request.method, request.nextUrl.pathname);
const response = await middleware(request);
console.log('Response:', response.status);
return response;
};
}
function withHeaders(middleware: MiddlewareFunction): MiddlewareFunction {
return async (request) => {
const response = await middleware(request);
response.headers.set('X-Custom-Header', 'Value');
return response;
};
}
// Chain middlewares
function chain(
functions: MiddlewareFactory[],
index = 0
): MiddlewareFunction {
const current = functions[index];
if (current) {
const next = chain(functions, index + 1);
return current(next);
}
return () => NextResponse.next();
}
export default chain([withLogging, withAuth, withHeaders]);
export const config = {
matcher: '/dashboard/:path*'
};
Exercise: Create a comprehensive middleware system that includes:
- Authentication check for /admin and /dashboard routes
- Role-based access control (redirect based on user role)
- Locale detection and redirect for internationalization
- Rate limiting for API routes
- Security headers for all responses
- Request logging with unique request IDs
Use proper matcher configuration and organize your code with reusable functions.
Summary
Middleware in Next.js is a powerful feature for handling cross-cutting concerns before requests reach your pages or API routes. Key takeaways:
- Middleware runs on the Edge Runtime for optimal performance
- Define middleware in a middleware.ts file at the project root
- Use matchers to control which routes trigger middleware
- Common use cases: authentication, localization, A/B testing, rate limiting
- Return NextResponse.next(), redirect(), rewrite(), or json() based on your needs
- Access request properties like cookies, headers, and geo information
- Modify responses by adding headers or setting cookies
- Chain multiple middleware functions for complex logic