Vercel Edge Middleware

Vercel Edge Middleware runs lightweight JavaScript in a V8 isolate at the network edge, intercepting every matched request before it reaches your application code, so you can rewrite, redirect, authenticate and decorate traffic with single-digit-millisecond overhead.

Key points:

  • Middleware executes in a constrained edge runtime (Web APIs only, no Node.js built-ins) before rendering, API routes, and static delivery — design for sub-50ms CPU budgets.
  • Deterministic routing is built from NextResponse.rewrite(), redirect(), next(), and a precise matcher that keeps the function off static assets.
  • Geo, headers and cookies are the primary signals for steering, A/B testing, and tenant isolation in multi-tenant SaaS.
  • Cache-Control discipline at the edge prevents stale auth state, redirect-loop cache poisoning, and CDN-wide incidents.

This page is part of Edge Routing & Serverless Function Architecture and focuses on the Vercel-specific implementation of edge logic. Where the mechanism is portable, it links across to sibling platforms so you can compare execution models and pick the right edge for each workload.

Vercel Edge Middleware request lifecycle A client request hits the nearest edge PoP, the matcher decides whether middleware runs, and the middleware returns next, rewrite, or redirect before the origin or static cache is reached. Edge Middleware request lifecycle Client HTTP request Nearest edge PoP V8 isolate matcher test path in scope? Static / cache no middleware skip middleware() runs geo, headers, cookies, KV match next() continue, add headers rewrite(url) internal, URL unchanged redirect(url) 3xx to client Origin function / SSR / static asset

Architecture and execution context

Middleware runs in a V8 isolate, the same primitive that powers Cloudflare Workers Routing, distributed across Vercel’s global edge network. There is no container, no cold Node.js process, and no warm-up of a full server — the isolate boots in well under a millisecond and shares the process with thousands of other tenants. That model is what makes per-request interception cheap, but it also dictates the constraints you must design around.

Execution happens strictly before Next.js rendering, before API route resolution, and before static asset delivery. The middleware sees the raw request, can short-circuit it entirely, and otherwise hands a (possibly modified) request down the chain. Because it sits in front of everything, a bug here fails the whole route, so the resource ceilings below are hard limits, not advisory.

Constraint Limit Operational impact
Bundle size ~1 MB (gzipped) Heavy dependencies are rejected at build/deploy time.
CPU time ~50 ms wall per invocation Overrun terminates the request with a 500.
Memory ~128 MB shared isolate budget Large in-memory state risks eviction under pressure.
Runtime Web standard APIs only fs, net, crypto (Node build), and native addons are unavailable.

Place middleware.ts (or .js) at the project root, or inside src/ if that is your source directory. A minimal file declares the runtime implicitly — Next.js compiles middleware to the Edge Runtime automatically — but you should pin Node tooling versions in package.json to keep CI builds reproducible:

{
  "engines": {
    "node": ">=18.0.0"
  }
}

Every I/O path must be asynchronous and bounded. A synchronous loop, an unbounded fetch to a slow origin, or a large JSON parse can blow the CPU budget and surface as intermittent 500s that are painful to reproduce because they depend on input size and PoP load.

The word CPU in that limit is load-bearing. The clock only advances while your code is on the processor, which means the arithmetic is far less intuitive than it first appears:

What actually spends the CPU budget Four bars comparing the CPU cost of a backtracking regular expression, a large JSON parse, a hash of a session id, and an awaited origin fetch that consumes no CPU at all despite hundreds of milliseconds of wall time. Only work on the processor counts against the budget regex with backtracking 31 ms JSON.parse of a 200 KB body 18 ms SHA-256 over a session id under 1 ms await fetch(), 380 ms wall 0 ms — waiting is free 0 10 ms 20 ms 30 ms Two synchronous operations can exhaust the budget that a 380 ms origin wait never touches.

The practical consequence is that the fix for a middleware timeout is almost never “make the network faster.” It is to find the one synchronous operation that scales with input size and either bound it, hoist it to module scope so it is paid once per isolate, or move it behind the origin. A regular expression written for readability rather than for a linear match is the most common single offender, because it passes every test you write with short inputs and only degrades when a real user sends a long path or a fat cookie header.

Core routing and request transformation

Deterministic steering is built from three response primitives and one matcher. NextResponse.next() passes the request through, optionally with added or rewritten headers. NextResponse.rewrite(url) serves a different path internally while the browser URL stays the same — ideal for proxying, localization, and tenant fan-out. NextResponse.redirect(url) returns a 3xx to the client and changes the visible URL. The matcher decides which requests ever reach the function at all, and getting it right is the single biggest lever on both correctness and cost.

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

export const config = {
  // Run on everything except static assets and image optimizer output.
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

export function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();

  if (url.pathname.startsWith('/docs')) {
    url.pathname = url.pathname.replace(/^\/docs/, '/documentation');
    return NextResponse.rewrite(url);
  }

  const res = NextResponse.next();
  res.headers.set('x-edge', '1');
  return res;
}

This keeps the function off _next/static and favicon.ico so static delivery stays on the fast path, transparently maps /docs/* onto /documentation/* without a client-visible redirect, and stamps a marker header on everything else. Reshaping the inbound request — adding x-tenant-id, normalizing Accept-Language, or stripping client-supplied trust headers — is the same class of work covered in depth under Request/Response Transformation; the Vercel idiom is simply to mutate the headers on the NextResponse you return.

Cookies follow the same pattern. Read with req.cookies.get('session') and write with res.cookies.set(name, value, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600 }). Never echo a client-supplied cookie back as trusted state without validation, because the edge is the first place an attacker’s header reaches your stack.

Provider-specific implementation

Vercel (native middleware)

The canonical Vercel pattern is a single middleware.ts returning a NextResponse. Geo data is attached to the request automatically and is the basis for region-aware routing:

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

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

export function middleware(req: NextRequest) {
  const country = req.geo?.country ?? 'US';
  const res = NextResponse.next();
  res.headers.set('x-request-region', country);

  if (country === 'DE' || country === 'FR') {
    const eu = req.nextUrl.clone();
    eu.pathname = `/eu${req.nextUrl.pathname}`;
    return NextResponse.rewrite(eu);
  }
  return res;
}

Vercel Edge Functions (Web handler)

For non-Next.js projects, the same runtime is exposed as an Edge Function with a Web Request/Response signature. This is closer to the raw isolate and useful when you want middleware-style logic without the Next.js routing layer:

export const config = { runtime: 'edge' };

export default function handler(req: Request): Response {
  const url = new URL(req.url);
  if (url.pathname === '/healthz') {
    return new Response('ok', { headers: { 'cache-control': 'no-store' } });
  }
  return new Response(null, {
    status: 307,
    headers: { location: '/app' + url.pathname },
  });
}

Cloudflare Workers (portable equivalent)

The same logic ports almost verbatim to a Worker because both run V8 isolates over Web APIs — the difference is the entrypoint and the rewrite mechanism (a re-issued fetch rather than a framework helper):

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/docs')) {
      url.pathname = url.pathname.replace(/^\/docs/, '/documentation');
      return fetch(new Request(url, request));
    }
    return fetch(request);
  },
};

AWS Lambda@Edge / CloudFront (event-shaped)

On AWS the model is event-driven rather than fetch-driven: a viewer-request handler receives a CloudFront event, mutates request.uri or request.headers, and returns it. The latency and cold-start profile differs materially, which is the subject of the dedicated comparison guides below.

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  if (request.uri.startsWith('/docs')) {
    request.uri = request.uri.replace(/^\/docs/, '/documentation');
  }
  request.headers['x-edge'] = [{ key: 'x-edge', value: '1' }];
  return request;
};

Matcher precision and multi-tenant host routing

The matcher is the only mechanism that keeps middleware off requests it has no business touching, and it is worth treating as production configuration rather than a convenience. Each entry is compiled to a path-to-regexp pattern at build time, so an over-broad negative lookahead is not merely wasteful — it enlarges the compiled artifact and puts every asset request through an isolate that will do nothing but call next().

Beyond a bare path string, a matcher entry can be an object carrying has and missing conditions, which lets you narrow on headers, cookies or query parameters without running the function at all:

export const config = {
  matcher: [
    {
      source: '/((?!_next/static|_next/image|favicon.ico).*)',
      missing: [{ type: 'header', key: 'x-prerender-revalidate' }],
    },
    {
      source: '/api/:path*',
      has: [{ type: 'cookie', key: 'tenant' }],
    },
  ],
};

The first entry keeps middleware off Next.js revalidation traffic, which otherwise pays the full interception cost on every background regeneration. The second runs the API branch only for requests that already carry a tenant cookie, so anonymous probes never reach the function. Conditions are evaluated before the isolate is invoked, which is why they are cheaper than the equivalent if statement inside your handler.

Multi-tenant routing is the workload that exercises all of this at once. Custom domains all land on the same deployment, so the Host header — not the path — is the discriminator:

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

const RESERVED = new Set(['www', 'app', 'admin', 'api']);

export function middleware(req: NextRequest) {
  const host = req.headers.get('host')?.split(':')[0] ?? '';
  const label = host.endsWith('.example.com') ? host.split('.')[0] : null;

  if (!label || RESERVED.has(label)) return NextResponse.next();

  const url = req.nextUrl.clone();
  url.pathname = `/t/${label}${url.pathname}`;
  const res = NextResponse.rewrite(url);
  res.headers.set('x-tenant', label);
  return res;
}

Three details separate this from a demo. Stripping the port before parsing keeps local development working, where the host arrives as acme.example.com:3000. The reserved-label set prevents a tenant from registering admin and rewriting themselves into your control panel. And the injected x-tenant header gives the origin a value it can trust, provided you also strip any client-supplied copy of that header — a request arriving with its own x-tenant must never be believed, because the edge is the trust boundary. The same reasoning applies to every header your origin treats as authoritative, which is why header hygiene belongs in the middleware rather than in the application.

Wildcard domains add one operational wrinkle that has nothing to do with code: the certificate. A wildcard TLS certificate covers exactly one label depth, so acme.example.com is covered and eu.acme.example.com is not. Tenants on fully custom apex domains need their own certificate issued and verified before the routing above ever gets a chance to run, and a routing bug report that turns out to be a certificate provisioning delay is a common false start.

Platform comparison

Provider Mechanism Wire behavior Failover / notes
Vercel Edge Middleware NextResponse from middleware.ts Internal rewrite keeps URL; redirect sends 3xx Auto-deployed to all PoPs; rollback via vercel rollback.
Vercel Edge Function Web Response handler Returns Response directly Same runtime, no Next.js router; good for non-Next apps.
Cloudflare Workers fetch() handler, V8 isolate Rewrite via re-issued fetch(new Request(url)) Closest portable analog; routes bound by patterns. See Cloudflare Workers Routing.
AWS Lambda@Edge CloudFront event mutation Mutate request.uri / headers in place Higher cold-start, regional replication lag on deploy.

For head-to-head latency and throughput numbers, the Vercel Edge vs Cloudflare Workers performance comparison breaks down p50/p99 under load so you can choose per workload rather than by reputation.

Geo-targeting and conditional routing

Location-aware routing reads req.geo — populated by Vercel from the edge PoP — and branches on it. Always supply a fallback, because geo is undefined in local development and behind some corporate proxies, and a missing default silently routes everyone through one branch.

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

const EU = new Set(['DE', 'FR', 'NL', 'IE', 'ES', 'IT']);

export function middleware(req: NextRequest) {
  const country = req.geo?.country ?? 'US';
  const res = NextResponse.next();
  res.headers.set('x-request-region', country);

  if (EU.has(country)) {
    const eu = req.nextUrl.clone();
    eu.pathname = `/eu${req.nextUrl.pathname}`;
    return NextResponse.rewrite(eu);
  }
  return res;
}

This injects a region header for downstream services and rewrites EU traffic onto a compliant subtree. DNS-level and CDN-level geo steering live one layer below the application; when you need both — for example pinning a region at the resolver and refining at the edge — coordinate this with Geo-Targeted Traffic Routing. Mock the signal during development with a header your middleware reads first, e.g. const country = req.headers.get('x-debug-geo') ?? req.geo?.country ?? 'US';.

Cohort assignment is structurally the same conditional, just keyed on a sticky cookie or a hash of the visitor rather than geography. That pattern — assign once, persist via cookie, rewrite to a variant — is detailed in A/B testing with Vercel Edge middleware, and it is the cleanest way to run experiments without a client-side flicker, because the bucketing decision is made before any HTML is served.

Configuration and operational procedure

Bring middleware to production in a controlled sequence rather than shipping it straight to main:

  1. Author and scope. Write middleware.ts and define the tightest matcher that still covers your routes. A loose matcher invokes the function on assets you never meant to touch and inflates both latency and invocation count.
  2. Validate locally. Run vercel dev and exercise every branch — geo fallback, rewrite, redirect, and pass-through — using x-debug-geo and cookie overrides to force each path.
  3. Preview deploy. Push to a branch; Vercel builds a preview deployment with the middleware live on a unique URL. Test the real edge behavior, not just the local emulator, because the emulator does not reproduce PoP geo or true CPU limits.
  4. Inspect headers. Confirm x-vercel-cache (HIT/MISS/STALE) and x-vercel-id (which PoP served it) match expectations on representative routes.
  5. Promote. Merge to production; the deployment is atomic across all PoPs. There is no partial rollout window, so your preview testing is the safety net.
  6. Watch and roll back. Tail logs (below). If error rate or edge latency breaches your SLO, run vercel rollback to instantly repoint the alias at the previous deployment.
# Stream real-time edge logs for the current production deployment
vercel logs --follow

# Emulate the edge runtime locally with an inspector attached
vercel dev --listen 3000

# Instantly revert the production alias to the previous deployment
vercel rollback

Caching, TTL, and propagation implications

Middleware itself is not cached — it runs on every matched request — but the responses it shapes are, so its header decisions directly govern CDN behavior. The rule of thumb: set caching by route sensitivity, never globally. Public assets want long, immutable TTLs; SSR responses benefit from short shared TTLs with background revalidation; authenticated routes must never enter a shared cache.

Route type Recommended Cache-Control Purpose
Static / public public, max-age=31536000, immutable Maximize CDN hit ratio for fingerprinted assets.
Dynamic / SSR public, s-maxage=3600, stale-while-revalidate=86400 Shared cache with background refresh; no user-blocking miss.
Authenticated / API private, no-store, max-age=0 Prevent cross-user leakage and stale sessions.

Three questions decide which row a given route belongs in, and they have to be answered in this order — a user-specific body is never cacheable no matter how cheap it is to regenerate:

Choosing a Cache-Control policy Three questions in order — is the body user-specific, is the URL fingerprinted, is staleness tolerable — leading to four different Cache-Control policies. Answer these in order — the first yes wins User-specific body? session, cart, account yes private, no-store never enters a shared cache no Fingerprinted URL? content hash in the name yes max-age=31536000 plus immutable no Staleness tolerable? for an hour, say yes s-maxage=3600 stale-while-revalidate=86400 no no-cache revalidate on every hit
import { NextRequest, NextResponse } from 'next/server';

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

  if (path.startsWith('/api') || path.startsWith('/account')) {
    res.headers.set('Cache-Control', 'private, no-store, max-age=0');
    res.headers.set('X-Content-Type-Options', 'nosniff');
    res.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  } else {
    res.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
  }
  return res;
}

You can also pin behavior declaratively in vercel.json, which is useful when the same rule must apply regardless of whether middleware runs:

{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [{ "key": "Cache-Control", "value": "private, no-store" }]
    }
  ]
}

A critical propagation gotcha: if middleware ever issues a redirect on a cacheable path, the CDN can cache the 3xx. A subtle bug that occasionally redirects then becomes sticky across every visitor hitting that PoP until the cache expires. Keep redirects on no-store paths, or gate them so the redirecting branch is never reachable on a cacheable route. The deeper mechanics of shared-cache TTLs and revalidation behavior are covered under the stale-while-revalidate guide.

Debugging and production observability

Edge debugging is header-driven. console.log() and console.error() stream to vercel logs --follow and the dashboard, but verbose logging on a hot path costs CPU, so log at boundaries (decision taken, branch chosen) rather than per-line. The two headers that matter most are x-vercel-cacheHIT, MISS, STALE, or BYPASS — and x-vercel-id, which encodes the serving PoP and helps you correlate a slow or misrouted request to a specific region.

# Trace cache status and serving PoP for a route
curl -sI https://example.com/docs/intro | grep -i 'x-vercel'

# Watch only error-level edge output
vercel logs --follow | grep -i error

When a request misbehaves, work top-down: confirm the matcher actually included the path (a request that skips middleware shows no marker header), then confirm which branch ran, then inspect the resulting Location/Cache-Control. If you suspect a redirect loop, count hops with a temporary x-redirect-count header and bail when it exceeds a small threshold. For multi-platform incidents — say you front Vercel with another CDN — compare the execution and failover model against Cloudflare Workers Routing so you know which layer owns the decision. If edge latency or error rate breaches SLO, vercel rollback is the fastest mitigation and should be the first move, with root-cause investigation following on the now-stable previous deployment.

Named troubleshooting scenarios

Four failures account for most middleware incidents, and each has a distinct signature you can read off the response before opening a log.

The marker header is missing entirely. You set x-edge on every response, and it is absent. That is not a bug in your branching — it means middleware never ran, so the matcher excluded the path. Reproduce by requesting the same path with a trailing slash, with a different extension, and with a query string; matcher regexes frequently behave differently across those three. The fix is almost always a negative lookahead that is broader than intended, such as an api exclusion that also swallows /api-docs.

Intermittent 500s that correlate with payload size. The response is a generic edge error, there is no application stack trace, and the failure rate rises with traffic. This is a CPU overrun, and the correlation with size is the tell. Instrument the handler with a Server-Timing header carrying its own elapsed time, deploy to a preview, and drive it with progressively larger cookies and paths until the number climbs. Whatever grows superlinearly is your culprit.

Every visitor sees the same personalized page. The middleware branches correctly in local development but the deployed site serves one render to everybody. The response was cached without the branching signal in its key. Confirm with x-vercel-cache: HIT on a request whose cookie differs from the one that populated the entry, then either add the signal to the cache key or mark the route private, no-store. Serving a personalized body from a shared cache is a data-leak class bug, not a performance one, so treat a suspected instance as an incident rather than a backlog item.

A rewrite works locally and 404s in production. The rewrite target exists as a file in your repository but not as a route in the deployed output — typically because the target lives under a path excluded from the build, or because a dynamic segment resolves to a value with no generated page. Confirm by requesting the rewrite target directly: if /variant/pricing 404s on its own, the rewrite is faithfully sending traffic to a page that does not exist, and the middleware is innocent.

Edge cases and gotchas

  • Infinite redirect loops poison the CDN and can trip account-level protections. Always compare the target path against the current path before redirecting, and pass through with next() when they match.
  • Bundle over the size limit fails the deploy outright. Audit package.json for Node-only modules, prefer Web-API libraries, and tree-shake; a single crypto/fs-dependent import can sink the whole function.
  • Undefined req.geo in local dev and behind proxies routes everyone through the fallback branch. Use the nullish-coalescing default and a x-debug-geo override so you can exercise every region.
  • Cookies over ~4 KB truncate headers and silently drop sessions. Store only a session ID or compact JWT at the edge and offload heavy state to Edge KV or an external store.
  • CPU overrun is input-dependent. A handler that passes in dev can 500 in production on a larger payload or a busier PoP. Bound every parse and loop; never trust that “it worked locally.”
  • Caching a redirect on a public path makes a transient bug permanent for that PoP. Keep middleware redirects on no-store routes.
  • A rewrite target that is itself matched re-enters the function and compounds the path prefix on every pass. Exclude the target in the matcher and guard with a startsWith check in code; one alone is not enough, because a matcher regex can miss a path shape you did not anticipate.
  • Trusting a client-supplied x-forwarded-* or tenant header hands an attacker your routing decision. Overwrite these unconditionally on the way in rather than reading them.
  • Streaming responses and header mutation do not mix freely. Once the first byte of a body is on the wire the headers are sent; a decision that needs to alter headers must be made before you begin streaming.
  • req.nextUrl.clone() is not free of surprises. Mutating pathname leaves search intact, so rewriting a path while forgetting an existing query string silently changes behavior for URLs that carry one. Assert on both in tests.
  • Middleware runs for the prerender revalidation request too, which means a branch keyed on a cookie sees no cookie during background regeneration and silently regenerates the wrong variant. Exclude revalidation traffic in the matcher.

Frequently Asked Questions

Can Vercel Edge Middleware modify DNS records or TTL values? No. Middleware runs at the application layer after DNS resolution and CDN routing have already happened. DNS records and their TTLs are managed at your registrar or in the Vercel DNS dashboard, not from middleware code.

How do I stop middleware from running on static asset requests? Scope it with the config.matcher array, excluding paths like /_next/static, /_next/image, /favicon.ico, and any asset prefixes. A tight matcher keeps static delivery on the fast path and cuts unnecessary invocations and cost.

What happens if middleware exceeds the ~50 ms CPU budget? The request is terminated with a 500. Because the limit is wall-CPU and input-dependent, optimize by removing synchronous work, bounding I/O, and moving heavy computation to a regular serverless or origin function. The Vercel Edge vs Cloudflare Workers performance comparison shows where each runtime sits under sustained load.

Is middleware compatible with multi-tenant SaaS and custom domains? Yes. It executes per request and can read req.headers.get('host'), cookies, or a tenant token to route to tenant-specific origins, inject x-tenant-id, and enforce isolation — all before any rendering happens, which is also why it is the natural place to run A/B testing without a visible flicker.

Can middleware read a database or a secret store? It can call anything reachable over HTTPS with fetch, but a network round trip from the edge to a regional database frequently costs more than the request it is trying to accelerate. Keep the hot path to data that is already at the edge — a signed cookie, a JWT claim, or an edge key-value store — and treat a database read inside middleware as a design smell worth escalating to the origin.

Why does a rewrite still show the old page after a deploy? Because the rewrite decision and the cached body are two separate clocks. The new middleware is live immediately, but a response that was already stored under that URL keeps serving until its shared TTL expires. Confirm by requesting the URL with a random query parameter: if the busted request shows the new content, nothing is broken and the fix is an invalidation rather than another deploy.

Back to Edge Routing & Serverless Function Architecture