Next.js
SSR frameworks like Next.js make server-side fetch calls that go through the proxy without a browser context. The proxy identifies which session those requests belong to via the x-test-rcrd-id header. Playwright’s playwrightProxy.before() already sets it on the browser navigation that triggers SSR, so the id is available in next/headers — the job is to attach it to outgoing server-side requests. (Browser-only tests need none of this; the proxy falls back to the globally set session.)
registerProxyFetch (recommended)
Section titled “registerProxyFetch (recommended)”One line in your root layout tags every server-side fetch — Server Components, Route Handlers, on the Node and Edge runtimes:
import { registerProxyFetch } from 'test-proxy-recorder/nextjs';
registerProxyFetch(); // no-op in production unless TEST_PROXY_RECORDER_ENABLED=trueIt patches the global fetch to copy the current request’s x-test-rcrd-id onto outgoing requests, so the proxy can tell concurrent replay sessions apart. Call it from the root layout — not instrumentation.ts, whose context differs from the one rendering your routes on the Edge runtime, so a patch there silently never fires.
axios — registerProxyAxios
Section titled “axios — registerProxyAxios”If your server-side requests go through axios, register each server-side instance once:
import { registerProxyAxios } from 'test-proxy-recorder/nextjs';
registerProxyAxios(axiosForServer);It adds a request interceptor that stamps the id (never touching global fetch), so it’s immune to the dev-server caveat above. No-op in production / in the browser; idempotent per instance; never overwrites a caller-set id.
Per-call — createHeadersWithRecordingId
Section titled “Per-call — createHeadersWithRecordingId”Patch-free, and works under next dev too. Use it for a single fetch, or when you’d rather not patch global fetch:
import { headers } from 'next/headers';import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
const res = await fetch('http://localhost:8100/api/data', { headers: createHeadersWithRecordingId(await headers(), { 'Content-Type': 'application/json', }),});Middleware (optional)
Section titled “Middleware (optional)”A proxy.ts (Next.js 16+, exported proxy) or middleware.ts (15 and earlier, exported middleware) calling setNextProxyHeaders makes the id available via next/headers, but does not tag outgoing fetches — so it is not required when you use one of the helpers above. Reach for it only if you already own a middleware (auth, etc.), and still pair it with a helper to do the tagging:
// proxy.ts (Next.js 16+)import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
export function proxy(request: NextRequest) { const response = NextResponse.next(); setNextProxyHeaders(request, response); // exposes the id; pair with a helper above return response;}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],};See the API reference for the full signatures of the test-proxy-recorder/nextjs helpers. A complete, runnable Edge project lives in the Edge runtime example.
Caching & ISR
Section titled “Caching & ISR”Don’t disable caching for tests — the recorder works with a cached/ISR route. But there’s one rule that decides the whole design: to replay an SSR fetch, the page must run that fetch at request time. A route that serves prerendered HTML or a stale cached render never makes the fetch, so the proxy has nothing to serve and the assertion sees stale content.
The way that stays deterministic is to cache the SSR fetch with fetch-level next.revalidate + next.tags, then invalidate on demand before the assertion:
// app/isr/page.tsx — no `export const dynamic`, no `export const revalidate`const res = await fetch(`${BACKEND_URL}/todos`, { next: { revalidate: 30, tags: ['isr-todos'] },});import { revalidateTag } from 'next/cache';revalidateTag('isr-todos', 'max'); // Next.js 16 requires the 2nd profile argawait page.request.post('/api/revalidate'); // hard purgeawait page.goto('/isr'); // one navigation — deterministicawait expect(page.getByTestId('todo-text')).toHaveCount(1);revalidateTag on a fetch cache entry is a hard purge: the next read is a cache miss that blocks and re-fetches through the proxy. You must purge before the replay navigation because the data cache survives across the record → replay phases of one next start process — otherwise replay serves the record-phase cache and never hits the proxy (a false pass).
During tests the patched fetch reads headers(), so the page renders dynamically and actually runs the fetch. In production (recorder disabled) nothing reads headers() and the page is static ISR as usual — the dynamic render is scoped to tests, and is intrinsic to recording an SSR fetch.
On-demand revalidation is privileged (it purges the cache and forces regeneration), so gate the route behind a shared secret — fail closed if it’s unset, compare in constant time, and attach the token from the test via Playwright use.extraHTTPHeaders so the spec never handles it.
See the full, runnable example (part of the Next.js 16 example):
app/isr/page.tsx— the cached page (fetch-levelnext.tags)app/api/revalidate/route.ts— how to guardrevalidateTag: fail-closed + constant-time secret comparee2e/isr.spec.ts— invalidate, then one navigation; asserts the revalidate call succeededplaywright.config.ts— loads.envand attaches the secret viaextraHTTPHeaders
package.json scripts
Section titled “package.json scripts”Start services from scripts, not from playwright.config.ts:
{ "scripts": { "mock": "node mock-backend/server.mjs", "proxy": "test-proxy-recorder http://localhost:3002 -p 8100 -d ./e2e/recordings", "start:all": "concurrently \"pnpm mock\" \"pnpm proxy\" \"pnpm build && next start --port 3000\"" }}A complete, runnable project lives in the Next.js 16 example.