MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js Middleware: Edge Functions for Routing

Use Next.js middleware: authentication, A/B testing, geolocation, and redirects.

Next.jsMiddlewareEdgeFrontend

By MinhVo

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.

Edge Computing Architecture

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:

  1. Client Request: Browser sends request to the nearest CDN edge node
  2. Middleware Execution: Your middleware function runs at the edge
  3. Routing Decision: Middleware can rewrite, redirect, or pass through
  4. Page/API Execution: The matched route handler runs (SSR, SSG, or API)
  5. 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.

Middleware Request Flow

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).*)'],
};

Middleware Implementation Patterns

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

  1. Keep Middleware Lightweight: The Edge Runtime has a 1MB bundle size limit. Avoid importing heavy libraries. Use edge-compatible packages and tree-shake aggressively.

  2. Use Specific Matchers: Don't run middleware on static assets. Configure your matcher to exclude _next/static, _next/image, and favicon.ico paths.

  3. 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.

  4. Minimize External Calls: Every external API call in middleware adds latency. Cache responses at the edge or use stale-while-revalidate patterns.

  5. Test with next build && next start: Middleware behaves differently in development mode. Always test with a production build to verify edge runtime behavior.

  6. 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.

  7. Implement Circuit Breakers for External Services: If your middleware depends on external services (auth, feature flags), implement circuit breakers to prevent cascading failures.

  8. 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

PitfallImpactSolution
Importing Node.js APIsBuild failures at the edgeUse Web API equivalents or edge-compatible packages
Running middleware on all pathsUnnecessary overhead on static assetsConfigure specific matcher patterns
Heavy computation in middlewareIncreased latency for all requestsMove heavy logic to API routes or server components
Not handling edge runtime limits1MB bundle size exceededUse dynamic imports and tree-shaking
Accessing process.env directlyUndefined variables at the edgeUse process.env.VAR_NAME only for explicitly exposed variables
Synchronous crypto operationsNot available in edge runtimeUse 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

FeatureNext.js MiddlewareExpress MiddlewareEdge FunctionsServer Components
Execution LocationEdge (CDN)Origin serverEdge (CDN)Origin server
LatencySub-millisecond50-200msSub-millisecond50-200ms
Bundle Size Limit1MBNo limit1-10MBNo limit
Node.js APIsLimitedFullLimitedFull
Use CaseRouting, authFull backendAPI endpointsData fetching
Cost ModelPer requestPer serverPer requestPer 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:

  1. Middleware runs at the edge with sub-millisecond latency, ideal for routing and auth decisions
  2. Use the matcher configuration to avoid running middleware on static assets
  3. Keep middleware lightweight — the Edge Runtime has a 1MB bundle limit and restricted APIs
  4. Implement authentication, rate limiting, and security headers at the middleware level for consistent protection
  5. 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.