Next.js

Middleware in Next.js

20 min Lesson 12 of 40

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