Back/Module P-5 Middleware — Routing Logic at the Edge
Module P-5·23 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.

P-5 — Middleware: Routing Logic at the Edge

Who this is for: Practitioners who understand the App Router and have used auth in P-3, 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.

ts
// 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:

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

Array matcher:

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

Regex matcher with negation (most common pattern):

ts
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:

ts
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

ts
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

Middleware can set cookies and modify response headers, but with an important constraint: you can only write cookies and headers on the response, not on the request that gets passed to the page.

ts
export function middleware(request: NextRequest) { const response = NextResponse.next(); // ✅ Set a cookie on the response response.cookies.set('visited', 'true', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 365, // 1 year }); // ✅ Set response headers response.headers.set('X-Custom-Header', 'value'); // ❌ Cannot modify the request that reaches the page — this has no effect // request.headers.set('X-User-Id', userId); // doesn't work // ✅ Pass data to the page via the request URL or rewrite headers const requestHeaders = new Headers(request.headers); requestHeaders.set('X-User-Id', 'user-123'); // works via NextResponse.next({ request }) return NextResponse.next({ request: { headers: requestHeaders }, // ← modified request headers reach the page }); }

Passing custom headers to the page via NextResponse.next({ request: { headers } }) is the pattern for forwarding Middleware-derived data (user ID from JWT, A/B test variant, locale) to Server Components without making them re-read cookies.


Authentication in Middleware — The Right Pattern

From P-3, you've seen Auth.js's auth() wrapper for Middleware. Here's what's happening under the hood and how to write it manually when you need custom logic:

ts
// Manual JWT verification in Middleware (edge-compatible) import { jwtVerify } from 'jose'; // ← edge-compatible JWT library const JWT_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET); export async function middleware(request: NextRequest) { const token = request.cookies.get('session-token')?.value; if (!token) { // No token — redirect to login for protected routes if (request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } return NextResponse.next(); } try { const { payload } = await jwtVerify(token, JWT_SECRET); // Forward user data to pages via headers const headers = new Headers(request.headers); headers.set('X-User-Id', payload.sub as string); headers.set('X-User-Role', payload.role as string); return NextResponse.next({ request: { headers } }); } catch { // Invalid/expired token — clear it and redirect const response = NextResponse.redirect(new URL('/login', request.url)); response.cookies.delete('session-token'); return response; } }

jose is the standard edge-compatible JWT library — it uses Web Crypto APIs that work in V8 isolates. Don't use jsonwebtoken in Middleware; it uses Node.js crypto APIs that aren't available in the edge runtime.

In the Server Component, read the forwarded header:

tsx
// app/dashboard/page.tsx import { headers } from 'next/headers'; export default async function DashboardPage() { const headersList = await headers(); const userId = headersList.get('X-User-Id'); // set by Middleware const user = await getUserById(userId!); return <Dashboard user={user} />; }

This pattern avoids re-verifying the JWT in the Server Component — Middleware already did it. The Server Component just reads the pre-verified data from the forwarded header.


Redirects and Rewrites

Redirect:

ts
// Send the browser to a different URL (browser sees the new URL) return NextResponse.redirect(new URL('/new-path', request.url)); // Permanent redirect return NextResponse.redirect(new URL('/new-path', request.url), { status: 308 });

Rewrite:

ts
// Serve content from a different path without changing the browser URL return NextResponse.rewrite(new URL('/en/home', request.url));

Rewrites are the pattern for locale routing — detect the user's preferred locale from the Accept-Language header or a cookie, then rewrite the request to the localised version of the page without the user seeing /en/ in their URL:

ts
export function middleware(request: NextRequest) { const locale = detectLocale(request); // your locale detection logic const pathname = request.nextUrl.pathname; if (!pathname.startsWith(`/${locale}`)) { return NextResponse.rewrite( new URL(`/${locale}${pathname}`, request.url) ); } }

draftMode() — CMS Preview

draftMode() is a Next.js API that toggles "draft mode" for a request — allowing you to serve unpublished content from a CMS to authenticated editors while serving published content to regular visitors.

The typical implementation:

ts
// app/api/preview/route.ts import { draftMode } from 'next/headers'; import { NextRequest } from 'next/server'; import { redirect } from 'next/navigation'; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const token = searchParams.get('token'); const slug = searchParams.get('slug'); // Verify the preview token from the CMS if (token !== process.env.CMS_PREVIEW_TOKEN) { return new Response('Invalid token', { status: 401 }); } const { enable } = await draftMode(); enable(); redirect(`/blog/${slug}`); }
tsx
// app/blog/[slug]/page.tsx import { draftMode } from 'next/headers'; import { getPost, getDraftPost } from '@/lib/cms'; export default async function BlogPost({ params }) { const { slug } = await params; const { isEnabled } = await draftMode(); // Serve draft content to editors, published content to everyone else const post = isEnabled ? await getDraftPost(slug) // fetches from CMS draft API : await getPost(slug); // fetches from published content return <Article post={post} />; }

Draft mode sets a special cookie that bypasses the page cache — editors see live CMS content without triggering a rebuild.


Rate Limiting at the Edge

Middleware is where you put rate limiting — it runs before your application code, making it the cheapest place to reject bad actors:

ts
import { NextRequest, NextResponse } from 'next/server'; import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds }); export async function middleware(request: NextRequest) { // Only rate limit API routes if (!request.nextUrl.pathname.startsWith('/api')) { return NextResponse.next(); } const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'; const { success, limit, reset, remaining } = await ratelimit.limit(ip); if (!success) { return new Response('Too Many Requests', { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': reset.toString(), }, }); } return NextResponse.next(); }

Upstash Redis is the standard choice for edge rate limiting — it's HTTP-based (works in V8 isolates), globally distributed, and has a free tier. The @upstash/ratelimit package implements sliding window, fixed window, and token bucket algorithms.

Important: rate limiting in Middleware adds one Upstash Redis read to every API request. At high volume, verify this doesn't exceed your Upstash plan limits. For very high-traffic rate limiting, consider doing it at the load balancer or CDN level instead.


A/B Testing

Middleware is the right layer for A/B test assignment — before any page renders, Middleware reads or assigns a variant cookie, rewrites the request to the right variant URL, and passes the variant through:

ts
export function middleware(request: NextRequest) { const pathname = request.nextUrl.pathname; if (pathname === '/pricing') { let variant = request.cookies.get('pricing-variant')?.value; if (!variant) { variant = Math.random() < 0.5 ? 'control' : 'treatment'; } const response = NextResponse.rewrite( new URL(`/pricing/${variant}`, request.url) ); response.cookies.set('pricing-variant', variant, { maxAge: 60 * 60 * 24 * 30 }); return response; } }

/pricing/control and /pricing/treatment are regular pages in the App Router (app/pricing/control/page.tsx, app/pricing/treatment/page.tsx). The user always sees /pricing in their URL.


What Middleware Cannot Do

Understanding the limits prevents wasted debugging time:

Cannot use Node.js APIs. fs, path, child_process, native Node.js crypto — none of these are available. Use Web APIs: crypto.randomUUID(), fetch(), Headers, URL, TextEncoder.

Cannot import server-only modules. If a module imports anything that uses Node.js APIs (most ORMs, bcrypt, most auth libraries), it can't run in Middleware.

Cannot run slow operations. A 100ms database query in Middleware adds 100ms to every page load. Use Redis (HTTP) for the fastest external storage access, and only when necessary.

Cannot return React components. Middleware returns NextResponse (HTTP responses), not React trees. For auth redirects and rewrites, Middleware is correct. For rendering UI, Middleware is not.

Cannot directly call Server Actions. Middleware runs before the React render. Server Actions are part of the React render.


Where We Go From Here

P-6 is the largest module in the Practitioner phase — the four-layer caching model that determines how data flows from your database to the user. You've seen bits of this in F-4 (fetch caching), P-1 (unstable_cache), and P-3 (session-based bypass). P-6 puts the entire picture together: how the Request Memoization, Data Cache, Full Route Cache, and Router Cache interact, and how the new use cache directive in Next.js 15 changes the model significantly.

Understanding the caching model is what separates applications that cost $50/month to run from applications that cost $50/day.

Discussion