Skip to content

3.7 — Middleware, edge & runtimes

Module 3 · Lesson 7 · 🟡 Intermediate · ~30 min

What you'll learn

  • Why middleware is the most expensive 20 lines in most Next.js commerce apps
  • Matcher configuration that actually excludes what you think it does
  • Edge vs Node runtime: the real trade‑offs for commerce workloads
  • Where to run geo, A/B, and auth logic instead

Middleware runs on every matching request

That's the whole lesson in one sentence. Middleware executes before the cache, before rendering, on every request that matches — including static assets if your matcher is sloppy, and including requests that would otherwise be served entirely from the CDN.

Request → [middleware] → cache lookup → render → response
             every single request pays this

At Aurora's 38M sessions/month with ~14 requests per session, middleware runs roughly 530M times a month. A 40 ms middleware is 40 ms added to every TTFB, including cache hits that would otherwise be 20 ms. It doubled the TTFB of the pages they'd just spent a quarter making static.

Version note: Next.js 16 renames middleware.ts to proxy.ts. The execution model and everything in this lesson is unchanged; only the filename and some typings differ. Check your version's docs for the exact file convention.


Fix the matcher first

The default matcher in most examples is far too broad.

// ❌ Runs on literally everything, including images and JS chunks
export const config = { matcher: '/:path*' };

// ⚠️ Common copy-paste — better, but still runs on every page request
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

// ✅ Explicit: only the paths that genuinely need middleware
export const config = {
  matcher: [
    '/',                    // home: market routing
    '/c/:path*',            // category: market routing + A/B
    '/p/:path*',            // product: market routing + A/B
    '/checkout/:path*',     // auth guard
    '/account/:path*',      // auth guard
  ],
};

Aurora's PLP/PDP didn't need middleware at all after they moved market routing into the URL structure. Removing it from those matchers cut ~28 ms from p75 TTFB across 58% of requests.

Audit what your matcher actually matches:

// scripts/test-matcher.ts — run your real matcher against real URL samples
const MATCHER = /^\/((?!api|_next\/static|_next\/image|favicon\.ico).*)$/;

const SAMPLES = [
  '/', '/c/womens-knitwear', '/p/wool-overshirt-navy',
  '/_next/static/chunks/main-abc.js',
  '/_next/image?url=%2Fhero.jpg&w=828',
  '/api/cart', '/robots.txt', '/sitemap-products-3.xml',
  '/assets/logo.svg', '/manifest.webmanifest',
];

for (const url of SAMPLES) {
  console.log(`${MATCHER.test(url) ? '🔴 RUNS' : '⚪️ skip'}  ${url}`);
}
// Anything static showing 🔴 is pure waste, multiplied by your request volume

Note that /robots.txt, /sitemap*.xml, /manifest.webmanifest, and files in /public all match the "common copy‑paste" pattern above. On a large catalog, sitemap requests from crawlers alone can be millions of unnecessary middleware invocations.


Keep middleware minimal

Middleware should do routing decisions only. No data fetching, no crypto beyond a signature check, no JSON parsing of large bodies.

// ❌ 180ms of middleware on every request
export async function middleware(req: NextRequest) {
  const session = await fetch(`${API}/session`, {                    // 90ms network call 💀
    headers: { cookie: req.headers.get('cookie') ?? '' },
  }).then((r) => r.json());

  const flags = await fetch(`${FLAGS_API}/evaluate`, { /* … */ });   // 70ms 💀
  const geo = await lookupGeo(req.ip);                               // 20ms 💀

  // …then routing decisions
}
// ✅ Pure, local, ~1-3ms
import { NextRequest, NextResponse } from 'next/server';

const MARKET_BY_COUNTRY: Record<string, string> = {
  US: 'us', CA: 'ca', GB: 'uk', DE: 'de', FR: 'fr',
};

export function middleware(req: NextRequest) {
  const res = NextResponse.next();

  // 1. Geo comes from a header the CDN already computed — no lookup
  const country = req.headers.get('x-vercel-ip-country')
    ?? req.headers.get('cf-ipcountry')
    ?? 'US';
  res.headers.set('x-market', MARKET_BY_COUNTRY[country] ?? 'us');

  // 2. A/B bucket from an existing cookie, or assign one deterministically.
  //    No network call — a hash, not a flag-service round trip.
  let bucket = req.cookies.get('ab_pdp_layout')?.value;
  if (!bucket) {
    bucket = hashToBucket(req.cookies.get('anon_id')?.value ?? crypto.randomUUID());
    res.cookies.set('ab_pdp_layout', bucket, {
      maxAge: 60 * 60 * 24 * 30, sameSite: 'lax', path: '/',
    });
  }
  res.headers.set('x-ab-pdp-layout', bucket);

  // 3. Auth guard: check for the presence of a cookie, don't validate it here.
  //    Validation happens in the route, where you can cache and where a failure
  //    can render a real page.
  if (req.nextUrl.pathname.startsWith('/checkout') && !req.cookies.has('session')) {
    return NextResponse.redirect(new URL('/login?next=/checkout', req.url));
  }

  return res;
}

function hashToBucket(seed: string): 'a' | 'b' {
  let h = 2166136261;
  for (let i = 0; i < seed.length; i++) {
    h ^= seed.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) % 2 === 0 ? 'a' : 'b';
}

Middleware budget: ≤ 5 ms p95. Measure it:

export function middleware(req: NextRequest) {
  const t0 = Date.now();
  const res = doMiddleware(req);
  res.headers.set('server-timing', `mw;dur=${Date.now() - t0}`);
  return res;
}

Then chart server-timing from your RUM. If middleware is over 5 ms, something in it is doing work that belongs elsewhere.


Middleware and caching: the interaction that surprises people

Middleware that sets a cookie or a Vary header can make responses uncacheable.

// ⚠️ Setting a cookie on every response can defeat CDN caching depending on
//    your CDN's configuration — many treat Set-Cookie as "do not cache".
export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  res.cookies.set('last_seen', Date.now().toString());   // 💀 on a cacheable page
  return res;
}
// ✅ Only set cookies when they're missing, and never on cacheable paths
export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  if (!req.cookies.has('anon_id')) {
    res.cookies.set('anon_id', crypto.randomUUID(), {
      maxAge: 60 * 60 * 24 * 365, sameSite: 'lax', path: '/', httpOnly: true,
    });
  }
  return res;
}

Also: middleware runs before the Full Route Cache lookup, so a rewrite changes which cache entry is consulted. That's how per‑market variants work — and it's how you accidentally fragment your cache into thousands of entries if you rewrite on something high‑cardinality (a user ID, a session, a raw UA string).

Cardinality rule: anything middleware puts into the routing decision multiplies your cache entries. 5 markets × 2 A/B buckets = 10 variants: fine. 5 markets × 2 buckets × 40 currencies × device class = 800 variants per URL: your cache is now useless.


Edge vs Node runtime

// Route-level runtime selection
export const runtime = 'edge';    // or 'nodejs' (the default)
Edge runtime Node runtime
Cold start ~0–5 ms 100–800 ms (serverless) / 0 (long‑running server)
Geographic distribution Runs near the user Runs in your region(s)
Available APIs Web standard subset Full Node.js
npm compatibility Limited (no fs, many packages fail) Full
Memory limits Tight (often 128 MB) Generous
CPU limits Strict, short‑lived Generous
Database access Needs HTTP‑based drivers or a proxy Native TCP drivers, connection pooling
Bundle size limits Strict (often 1–4 MB) Generous

When Edge wins: the work is cheap, latency‑sensitive, and doesn't need a database — routing, redirects, header manipulation, A/B assignment, geo, simple token verification, serving cached content.

When Node wins — which is most commerce rendering:

  1. Database proximity beats user proximity. A PDP render makes 3–6 backend calls. Running the render at the edge in Sydney while your database is in Virginia means 3–6 × 200 ms round trips. Running it in Virginia means one 200 ms round trip for the HTML and the rest is local. Edge rendering is slower than regional rendering whenever the page is data‑heavy — which is every commerce page.
Edge render in Sydney, DB in Virginia:
  user→edge 20ms + 5 sequential-ish DB calls × 200ms = ~1,020ms

Node render in Virginia, DB in Virginia:
  user→origin 200ms + 5 DB calls × 5ms (parallel) = ~225ms
  1. Connection pooling. Edge functions can't hold a TCP pool; every invocation needs an HTTP data proxy, which adds latency and a dependency.
  2. npm reality. Commerce apps depend on SDKs (payments, search, CMS) that often use Node APIs.

Aurora's split:

Layer Runtime Why
Middleware Edge Pure routing, no data
Page rendering Node, multi‑region Data‑adjacent; pooled connections
API routes (cart/checkout) Node DB + payment SDKs
Simple API routes (geo, health, redirects) Edge Cheap and latency‑sensitive

The real fix for global latency isn't the edge runtime — it's multi‑region Node deployment with regional read replicas. See 8.6.


Alternatives to middleware

For each common middleware job, there's usually a cheaper place to do it:

Job Middleware cost Better option
Geo → market routing Every request Market in the URL path (/uk/p/...); links generated correctly server‑side
A/B assignment Every request CDN edge worker (runs before your origin), or a cookie set once on first visit
Auth guard Every request on protected paths Fine in middleware (cookie presence only), validate in the route
Locale detection Every request Accept-Language handled once at the entry point, then persisted in the URL
Bot detection Every request CDN/WAF layer — it's better at it and it's free
Legacy URL redirects Every request CDN redirect rules or next.config.ts redirects() (handled before middleware)
Adding security headers Every request next.config.ts headers() — static, no runtime cost
// next.config.ts — these cost nothing at runtime; middleware costs on every request
const config: NextConfig = {
  async redirects() {
    return [
      { source: '/products/:slug', destination: '/p/:slug', permanent: true },
      { source: '/category/:slug', destination: '/c/:slug', permanent: true },
    ];
  },
  async headers() {
    return [{
      source: '/:path*',
      headers: [
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      ],
    }];
  },
};

A common anti‑pattern: 400 legacy redirects implemented as a lookup table in middleware. That table is parsed and searched on every request forever. Put them in redirects() (or better, in the CDN) where they cost nothing.


Common mistakes

Mistake Cost
Broad matcher Middleware on static assets and sitemaps
Network calls in middleware +50–200 ms on every request
Setting cookies on cacheable responses Defeats CDN caching
High‑cardinality rewrites Cache fragmentation
Full session validation in middleware Latency on every request; do a presence check instead
Edge runtime for data‑heavy rendering Slower than regional Node, by a lot
Redirect tables in middleware Permanent per‑request cost for a static mapping
Not measuring middleware duration It's invisible in most dashboards

Lab 3.7 — Middleware audit

  1. Measure it. Add the server-timing header and chart p50/p95 from RUM for a day.
  2. Test your matcher with the script above against 20 real URLs from your access logs. Count how many static/asset/sitemap requests it matches.
  3. Inventory the work. List everything middleware does. For each, check the alternatives table and move what you can.
  4. Remove all network calls. If middleware needs data, it's the wrong layer.
  5. Check cache interaction: does middleware set cookies or Vary headers on cacheable paths? Verify CDN hit ratio before/after tightening it.
  6. Reconsider Edge routes. For any route with runtime = 'edge', measure its p75 TTFB against a Node deployment in your data region. If it's data‑heavy, Node usually wins.

Checklist

  • Matcher is an explicit allowlist, not a broad exclusion regex
  • Middleware p95 ≤ 5 ms, measured via server-timing
  • Zero network calls in middleware
  • No cookies set on cacheable responses (except one‑time anon ID)
  • Rewrite cardinality is small and known
  • Redirects live in next.config.ts or the CDN, not middleware
  • Security headers set statically, not per request
  • Runtime chosen per route based on data proximity, not fashion

Next: Module 4 — JavaScript payload