Module P-6·12 min read

The middleware execution model, matcher config, cookies()/headers() write constraints, userAgent() for device routing, draftMode() for CMS preview, and the 1ms performance budget.

Written by Jatin Jain Saraf · Architect of SupraScan

P-6 — Middleware: Routing Logic at the Edge

Who this is for: Practitioners who understand the App Router and have used auth in P-4, and now need to work with Middleware directly — understanding its execution model, writing production-grade matchers, and knowing exactly what it can and cannot do. Middleware is powerful but bounded; understanding those bounds is what separates clean Middleware code from subtle bugs.


What Middleware Actually Is

middleware.ts at the project root defines a function that runs on the edge runtime — a V8 isolate, not a full Node.js process — before every matched request. It intercepts the request, can inspect it, modify response headers, set cookies, or redirect, and then either passes the request through or returns a response directly.

The edge runtime has two defining constraints:

  1. No Node.js APIs. No fs, no path, no crypto (Web Crypto is available), no native Node.js modules. This is intentional — the edge runtime is designed to be deployable at CDN edge locations globally, not just in a central Node.js server.

  2. Performance budget: 1ms target. Middleware runs on every matched request, before the page renders. Every millisecond spent in Middleware is added to every page's Time to First Byte. Keep it fast — read cookies, check a JWT, do a redirect. Don't query databases, don't make slow API calls.

// middleware.ts
 
import { NextRequest, NextResponse } from 'next/server';
 
export function middleware(request: NextRequest) {
  // Fast operations only — this runs before every matched request
  const response = NextResponse.next();
 
  response.headers.set('X-Request-Id', crypto.randomUUID());
  response.headers.set('X-Frame-Options', 'DENY');
 
  return response;
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

The Matcher — Controlling Which Routes Trigger Middleware

Without a matcher, Middleware runs on every request including static files, Next.js internal routes, and image optimization requests. This wastes compute and can cause surprising behaviour. Always define a matcher.

String matcher:

export const config = {
  matcher: '/dashboard/:path*', // ← all paths under /dashboard
};

Array matcher:

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/admin/:path*',
    '/api/:path*',
  ],
};

Regex matcher with negation (most common pattern):

export const config = {
  matcher: [
    /*
     * Match all paths except:
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico
     * - Files with extensions (images, fonts, etc.)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|woff2?)$).*)',
  ],
};

The pattern (?!...) is a negative lookahead — it matches everything except the listed patterns. This is the most robust matcher for production use: it skips static assets entirely, running Middleware only on page routes and API routes.

Conditional logic in the function itself:

export function middleware(request: NextRequest) {
  // You can also handle routing logic inside the function
  if (request.nextUrl.pathname.startsWith('/api/public')) {
    return NextResponse.next(); // skip auth check for public API
  }
 
  // ...auth check for everything else
}

Matchers and in-function conditions both work. Matchers are evaluated first and are more efficient because they prevent the function from running at all. In-function conditions are useful for nuanced logic that regex can't express cleanly.


Reading Cookies and Headers

import { NextRequest, NextResponse } from 'next/server';
 
export function middleware(request: NextRequest) {
  // Read a cookie
  const session = request.cookies.get('session')?.value;
  const theme = request.cookies.get('theme')?.value ?? 'dark';
 
  // Read headers
  const authHeader = request.headers.get('authorization');
  const contentType = request.headers.get('content-type');
  const country = request.headers.get('x-vercel-ip-country'); // Vercel-specific
 
  // Read URL parts
  const pathname = request.nextUrl.pathname;
  const searchParams = request.nextUrl.searchParams;
  const locale = searchParams.get('locale');
 
  // ...
}

Vercel-specific headers like x-vercel-ip-country, x-vercel-ip-city, and x-vercel-ip-continent give you geo-location data at the edge. Useful for geo-based redirects, A/B tests by region, or content personalisation. On self-hosted deployments, these headers don't exist — use a separate geo-IP service if needed.


Writing Cookies and Headers

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.