Next.js Middleware: Request Processing

Next.js middleware intercepts incoming requests before they reach your pages, API routes, or static assets. It executes on Vercel's Edge Network, running closer to your users with minimal latency....

Key Insights

  • Next.js middleware runs on the Edge runtime before requests complete, making it ideal for authentication, redirects, and header manipulation without cold start penalties
  • Use matcher patterns to selectively apply middleware only to specific routes, avoiding unnecessary execution overhead on static assets and API routes
  • Middleware cannot access the filesystem or use Node.js APIs—stick to lightweight operations like JWT validation, geolocation checks, and response header modification

Introduction to Next.js Middleware

Next.js middleware intercepts incoming requests before they reach your pages, API routes, or static assets. It executes on Vercel’s Edge Network, running closer to your users with minimal latency. This positioning in the request lifecycle makes middleware perfect for operations that need to happen before rendering: authentication checks, geographic redirects, A/B testing, and bot detection.

Unlike API routes that handle specific endpoints or Server Components that run during rendering, middleware acts as a gatekeeper. It can inspect every request, modify headers, rewrite URLs, or redirect users—all before Next.js decides how to handle the route. This makes it significantly more efficient than implementing these checks in individual page components or API handlers.

The key advantage is performance. Middleware runs on the Edge runtime, which means no cold starts and sub-50ms execution times globally. However, this comes with constraints: no filesystem access, no Node.js-specific APIs, and a 4MB bundle size limit. You’re working with Web APIs only.

Basic Middleware Setup

Create a middleware.ts file in your project root (same level as app or pages directory):

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const start = Date.now();
  const response = NextResponse.next();
  
  const duration = Date.now() - start;
  console.log(`${request.method} ${request.url} - ${duration}ms`);
  
  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

This basic middleware logs every request with timing information. The matcher configuration is crucial—it excludes Next.js internals and static assets. Without proper matchers, your middleware runs on every single request, including images and CSS files, which wastes Edge function invocations.

The NextRequest object extends the standard Web Request API with Next.js-specific helpers for cookies, geolocation, and URL manipulation. NextResponse.next() continues the request pipeline without modification, but you can also redirect, rewrite, or return custom responses.

Request Inspection and Manipulation

Middleware has full access to request details. You can read headers, inspect cookies, parse query parameters, and even access geolocation data provided by Vercel’s Edge Network:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl;
  const locale = request.headers.get('accept-language')?.split(',')[0] || 'en';
  const country = request.geo?.country || 'US';
  
  // Redirect users to localized content
  if (pathname === '/' && !searchParams.has('locale')) {
    const url = request.nextUrl.clone();
    url.pathname = `/${locale.toLowerCase()}`;
    return NextResponse.redirect(url);
  }
  
  // Add geographic context to the request
  const response = NextResponse.next();
  response.headers.set('x-user-country', country);
  response.headers.set('x-user-locale', locale);
  
  return response;
}

export const config = {
  matcher: '/',
};

This middleware detects the user’s preferred language from the Accept-Language header and redirects them to a localized homepage. It also adds custom headers that downstream routes can access. Your Server Components and API routes can read these headers to customize content without repeating the detection logic.

Request header modification is particularly powerful. You can inject authentication context, feature flags, or user segments as headers that your application code reads, creating a clean separation between request processing and business logic.

Response Modification Patterns

Middleware can modify responses in several ways. Rewrites change the destination URL without redirecting the browser, while response headers can enforce security policies or enable CORS:

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');
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
  );
  
  // Cookie management
  const sessionId = request.cookies.get('session')?.value;
  if (!sessionId) {
    response.cookies.set('session', crypto.randomUUID(), {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7, // 1 week
    });
  }
  
  return response;
}

This pattern adds security headers to every response and ensures all visitors have a session cookie. The cookie API is type-safe and handles serialization automatically. Setting httpOnly prevents JavaScript access, reducing XSS risks.

URL rewrites are invisible to the user but change what Next.js renders. This is perfect for A/B testing, feature flags, or serving different content based on user attributes without changing the browser URL.

Authentication and Authorization

Protecting routes with middleware is more efficient than checking authentication in every page component. Validate tokens once at the edge and redirect unauthorized users before rendering anything:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key'
);

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Public routes that don't require authentication
  if (pathname.startsWith('/login') || pathname.startsWith('/api/auth')) {
    return NextResponse.next();
  }
  
  const token = request.cookies.get('auth_token')?.value;
  
  if (!token) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    url.searchParams.set('redirect', pathname);
    return NextResponse.redirect(url);
  }
  
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    
    // Add user context to request headers
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.sub as string);
    response.headers.set('x-user-role', payload.role as string);
    
    return response;
  } catch (error) {
    // Invalid token - redirect to login
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    url.searchParams.set('redirect', pathname);
    
    const response = NextResponse.redirect(url);
    response.cookies.delete('auth_token');
    return response;
  }
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

This middleware validates JWTs using the jose library (which works in Edge runtime, unlike jsonwebtoken). Authenticated users proceed with their user ID and role injected as headers. Invalid or missing tokens trigger a redirect to login with the original destination preserved.

The pattern is clean: middleware handles authentication, routes handle authorization. Your page components can trust that if they render, the user is authenticated, and they can read user context from headers.

Advanced Patterns and Edge Cases

Feature flags and A/B testing are natural fits for middleware. You can segment users and rewrite URLs to different implementations without client-side JavaScript:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Only apply to specific routes
  if (!pathname.startsWith('/product')) {
    return NextResponse.next();
  }
  
  // Check for existing variant cookie
  let variant = request.cookies.get('ab_variant')?.value;
  
  // Assign variant if not set (50/50 split)
  if (!variant) {
    variant = Math.random() < 0.5 ? 'control' : 'experiment';
  }
  
  // Rewrite to variant-specific path
  const url = request.nextUrl.clone();
  url.pathname = `/product/${variant}${pathname.slice(8)}`;
  
  const response = NextResponse.rewrite(url);
  
  // Persist variant assignment
  response.cookies.set('ab_variant', variant, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
  });
  
  return response;
}

export const config = {
  matcher: '/product/:path*',
};

This middleware implements A/B testing by rewriting /product/* to either /product/control/* or /product/experiment/* based on a persistent cookie. Users see the same URL but get different implementations. Your analytics can track the variant from the cookie.

Be careful with matcher patterns when dealing with API routes. If you need different logic for API endpoints versus pages, check the pathname explicitly rather than relying solely on matchers.

Best Practices and Common Pitfalls

Use middleware sparingly. It runs on every matched request, so heavy computation or external API calls hurt performance. Stick to lightweight operations: header inspection, cookie manipulation, simple redirects, and JWT validation.

Avoid infinite redirect loops. Always check conditions before redirecting:

// BAD: Infinite loop
if (!isAuthenticated) {
  return NextResponse.redirect(new URL('/login', request.url));
}

// GOOD: Check if already on login page
if (!isAuthenticated && pathname !== '/login') {
  return NextResponse.redirect(new URL('/login', request.url));
}

Matcher patterns are your friend. Exclude static assets, images, and Next.js internals to reduce unnecessary executions. A typical matcher looks like:

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

Debug middleware by checking Edge function logs in your deployment platform. Local development runs middleware in a Node.js environment, which can mask Edge runtime limitations. Test thoroughly in preview deployments.

Remember that middleware cannot read or write files, use Node.js APIs, or import packages that depend on them. If you need database access or complex business logic, use API routes or Server Components instead. Middleware is for request/response transformation, not application logic.

When choosing between middleware and other Next.js features, ask: “Does this need to run before the route handler?” If yes, use middleware. If it’s route-specific logic, use Server Components or API route handlers. Middleware is infrastructure, not application code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.