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
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");
}