Proxy (middleware)

Run code before the page: rewrite, redirect or add headers based on the request.

What it is

src/proxy.ts (called Middleware before Next.js 16) runs before routing, for every path its matcher selects. It sees the request (URL, cookies, headers, Vercel's geo headers) and can rewrite to a different page under the same URL, redirect, set cookies, or add headers. Because a rewrite can point at a prerendered page, personalised routing doesn't cost you the static cache.

When to use it

  • A/B tests and experiments that pick between static pages
  • Geo or language routing
  • Quick auth checks that send signed-out visitors to a login page
  • Adding headers to a group of routes
Official docs ↗

1. Headers added by Proxy

Proxy copies the request headers and adds two of its own before this page renders. It also sets an x-lab-proxy response header with the rule it ran and how long it took: look for it in your browser's network tab.

Reading headers…

2. A/B test with a rewrite

/proxy/ab has no page of its own. Proxy picks bucket A or B at random, saves it in a cookie so you keep seeing the same one, and rewrites to one of two prerendered variants. The address bar keeps saying /proxy/ab.

Reading cookie…

Switch bucket: ?variant=a · ?variant=b

3. Geo routing

/proxy/geo is rewritten to a page for your country, read from Vercel's x-vercel-ip-country header. Locally there's no such header, so try ?country=JP or ?country=ZA.

4. Auth gate

/proxy/members is gated: Proxy checks for a signed session cookie and redirects you back here without one. Sign in with any name to get a cookie signed with SESSION_SECRET (HMAC-SHA256, httpOnly, one hour).

Show code · src/proxy.ts
import type { NextRequest } from "next/server";
import { abTest, authGate, ensureVisitor, geoRewrite, withProxyHeaders, withStrictCsp } from "@/lib/proxy-rules";

// Proxy (called Middleware before Next.js 16) runs before the route, on every
// request the matcher selects. On Vercel it runs as a Vercel Function, on the
// Node.js runtime by default. Keep it fast: it sits in front of every page it matches.
export async function proxy(request: NextRequest) {
  const started = performance.now();
  const { pathname } = request.nextUrl;

  const [rule, response] =
    pathname === "/security" ? ["strict-csp", withStrictCsp(request)] as const
    : pathname === "/edge-config" ? ["visitor-id", ensureVisitor(request)] as const
    : pathname === "/proxy/ab" ? ["ab-test", abTest(request)] as const
    : pathname === "/proxy/geo" ? ["geo", geoRewrite(request)] as const
    : pathname.startsWith("/proxy/members") ? ["auth-gate", await authGate(request)] as const
    : ["headers", withProxyHeaders(request)] as const;

  // A response header, visible in the browser's network tab.
  response.headers.set("x-lab-proxy", `${rule}; ${(performance.now() - started).toFixed(2)}ms`);
  return response;
}

// Only the lab's Proxy pages, /edge-config (visitor ids for feature flags)
// and /security (a per-request CSP nonce) go through Proxy. Without a
// matcher it would run on every request, static files included.
export const config = {
  matcher: ["/proxy/:path*", "/edge-config", "/security"],
};
Show code · src/lib/proxy-rules.ts
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE, readSession } from "@/lib/session";

// The rules src/proxy.ts applies, kept here so each one can be tested alone.

// Cookie options shared by the rules, matching the session cookie: no
// JavaScript access, and HTTPS-only in production (off locally, so
// http://localhost still works).
const cookieOptions = () => ({ httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production" });

export const AB_COOKIE = "lab-ab";
export const VARIANTS = ["a", "b"] as const;
export type Variant = (typeof VARIANTS)[number];
const isVariant = (v: string | null | undefined): v is Variant => VARIANTS.includes(v as Variant);

// Two-letter country from Vercel's geo header. `?country=JP` overrides it so
// the demo can be tried locally, where there is no header. Anything else
// becomes "XX", so random values can't create new cached pages.
export function visitorCountry(request: NextRequest) {
  const raw = (request.nextUrl.searchParams.get("country") ?? request.headers.get("x-vercel-ip-country") ?? "").toUpperCase();
  return /^[A-Z]{2}$/.test(raw) ? raw : "XX";
}

// A/B test: keep the visitor's bucket from the cookie, or pick one at random.
// `?variant=b` switches bucket. The URL stays /proxy/ab; only the page served changes.
export function abTest(request: NextRequest, random = Math.random) {
  const forced = request.nextUrl.searchParams.get("variant");
  const saved = request.cookies.get(AB_COOKIE)?.value;
  const variant: Variant = isVariant(forced) ? forced : isVariant(saved) ? saved : random() < 0.5 ? "a" : "b";
  const response = NextResponse.rewrite(new URL(`/proxy/ab/${variant}`, request.url));
  if (variant !== saved) {
    response.cookies.set(AB_COOKIE, variant, { ...cookieOptions(), path: "/proxy", maxAge: 60 * 60 * 24 * 30 });
  }
  return response;
}

// Geo routing: serve the page for the visitor's country under the same URL.
export function geoRewrite(request: NextRequest) {
  return NextResponse.rewrite(new URL(`/proxy/geo/${visitorCountry(request)}`, request.url));
}

// Auth gate: an optimistic check before the page runs. Without a valid
// session cookie the visitor is sent back to sign in. The page checks again:
// Proxy is a fast first filter, not the only line of defence.
export async function authGate(request: NextRequest, secret = process.env.SESSION_SECRET) {
  const back = (reason: string) => NextResponse.redirect(new URL(`/proxy?signin=${reason}#auth`, request.url));
  if (!secret) return back("unavailable");
  const session = await readSession(request.cookies.get(SESSION_COOKIE)?.value, secret);
  return session ? NextResponse.next() : back("required");
}

// Feature flags need a stable id per visitor to roll out to a percentage.
// A first visit gets a random one, set on the response and also on this
// request, so the page that's about to render sees it straight away.
export const VISITOR_COOKIE = "lab-visitor";

export function ensureVisitor(request: NextRequest) {
  if (request.cookies.has(VISITOR_COOKIE)) return NextResponse.next();
  const id = crypto.randomUUID();
  request.cookies.set(VISITOR_COOKIE, id);
  const response = NextResponse.next({ request: { headers: request.headers } });
  response.cookies.set(VISITOR_COOKIE, id, { ...cookieOptions(), path: "/edge-config", maxAge: 60 * 60 * 24 * 365 });
  return response;
}

// Everything else: pass the request on, adding headers the page can read.
// set() overwrites, so a client can't spoof these by sending them itself.
export function withProxyHeaders(request: NextRequest) {
  const headers = new Headers(request.headers);
  headers.set("x-lab-country", visitorCountry(request));
  headers.set("x-lab-proxy-at", new Date().toISOString());
  return NextResponse.next({ request: { headers } });
}

// Strict Content Security Policy for /security: a fresh nonce per request,
// so only scripts the server rendered (and scripts they load, thanks to
// 'strict-dynamic') can run. Next.js reads the nonce from the request's CSP
// header and adds it to its own scripts. Nonces only work on pages rendered
// per request, which is why the rest of the lab (prerendered) uses the
// site-wide policy in next.config.ts instead.
export function strictCsp(nonce: string, isDev = process.env.NODE_ENV === "development") {
  return [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'report-sample'${isDev ? " 'unsafe-eval'" : ""}`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: blob:",
    "font-src 'self'",
    "connect-src 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
    // report-uri sends each violation straight away. (Its successor, report-to,
    // batches reports for up to a minute and, when present, replaces it in
    // Chromium, which is too slow for a live demo.)
    "report-uri /api/security/csp-report",
  ].join("; ");
}

export function withStrictCsp(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const policy = strictCsp(nonce);
  const headers = new Headers(request.headers);
  headers.set("x-nonce", nonce);
  headers.set("content-security-policy", policy);
  const response = NextResponse.next({ request: { headers } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}
Show code · src/lib/session.ts
// A minimal signed session cookie for the Proxy auth-gate demo:
//   <base64url(name)>.<expiresAt ms>.<base64url(HMAC-SHA256 of the first two parts)>
// Signed with SESSION_SECRET via Web Crypto, so it works in Proxy and in pages.
// A real app would use an auth library; this shows the moving parts.

export const SESSION_COOKIE = "lab-session";
export const SESSION_MAX_AGE = 60 * 60; // seconds

export type Session = { name: string; expiresAt: number };

const encoder = new TextEncoder();

function hmacKey(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}

export async function createSession(name: string, secret: string, now = Date.now()) {
  const payload = `${Buffer.from(name).toString("base64url")}.${now + SESSION_MAX_AGE * 1000}`;
  const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(payload));
  return `${payload}.${Buffer.from(signature).toString("base64url")}`;
}

// Returns the session if the signature is valid and it hasn't expired.
// crypto.subtle.verify compares in constant time.
export async function readSession(token: string | undefined, secret: string, now = Date.now()): Promise<Session | null> {
  const parts = token?.split(".");
  if (!parts || parts.length !== 3) return null;
  const [name64, expires, signature64] = parts;
  const valid = await crypto.subtle.verify(
    "HMAC",
    await hmacKey(secret),
    Buffer.from(signature64, "base64url"),
    encoder.encode(`${name64}.${expires}`),
  );
  const expiresAt = Number(expires);
  if (!valid || !Number.isFinite(expiresAt) || expiresAt <= now) return null;
  return { name: Buffer.from(name64, "base64url").toString(), expiresAt };
}
Show code · src/app/proxy/actions.ts
"use server";

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE, SESSION_MAX_AGE, createSession } from "@/lib/session";

// Sign in to the demo members area: no password, just a name, so the page can
// show the signed cookie at work. The cookie is httpOnly (no JavaScript access)
// and sent site-wide: the Proxy auth gate and the Blob uploads both use it.
export async function signIn(formData: FormData) {
  const secret = process.env.SESSION_SECRET;
  if (!secret) redirect("/proxy?signin=unavailable#auth");
  const name = String(formData.get("name") ?? "").trim().slice(0, 30) || "guest";
  (await cookies()).set(SESSION_COOKIE, await createSession(name, secret), {
    path: "/",
    maxAge: SESSION_MAX_AGE,
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
  });
  redirect("/proxy/members");
}

export async function signOut() {
  (await cookies()).delete({ name: SESSION_COOKIE, path: "/" });
  redirect("/proxy#auth");
}