DEV Community

Alex
Alex

Posted on • Originally published at asmyshlyaev177.dev

Testing Next.js SSR with Playwright: record real responses instead of writing mocks

Recording real API responses, then replaying them on CI with the backend turned off

If you write Playwright tests for a Next.js app, you've probably hit this wall. You reach for page.route() to stub an API call, the test still fails, and after twenty minutes you realize the request you're trying to mock never went through the browser at all. Your server component fetched it during SSR. Playwright never saw it, because Playwright drives a browser, and that fetch happened in Node.

This is the gap nobody upstream has closed cleanly. routeFromHAR is browser only. The Next.js team has an open discussion about mocking server actions in e2e tests and another about middleware, both still unresolved. There's a four-year-old Playwright feature request asking for exactly the thing you want: record network requests, save them as ready-to-use mocks. Still open.

So people work around it. The current options are all variations on the same idea: hand-write the mocks.

The hand-written mock treadmill

MSW is the popular answer. It intercepts in both the browser and Node, and since Next.js 15 it can finally see server-side calls. But you still author every handler by hand, keep its shape in sync with the real API, and update it when the backend changes. MSW also keeps its state globally, so running tests in parallel gets fiddly fast. Mocky Balboa, the experimental Next.js test proxy, request-mocking-protocol, they all land in the same place. You're maintaining a second, fake copy of your API forever.

I did this for a while and got tired of it. The mocks drift. The real API adds a field, your mock doesn't have it, the test passes anyway, and you ship a bug that only shows up against production data. The whole point of an e2e test is to run against something that behaves like the real thing, and a mock I typed out by hand at 2am is the opposite of that.

What I actually wanted was the VCR pattern. Ruby had this nailed years ago: hit the real API once, save the interaction to a file, replay it from disk on every run after that. The JavaScript version of this was Polly.js from Netflix, but it's effectively unmaintained now (last meaningful push was a while back), and it never had a clean story for SSR plus Playwright anyway. The throne was empty. So I built test-proxy-recorder.

The idea: record once, replay forever

The model is simple. There are two modes.

In record mode, your app talks to the real backend through a proxy. The proxy forwards everything and writes each request and response to disk as it goes.

In replay mode, the proxy serves those saved responses straight from disk. No backend, no network, same bytes every time.

                        Record mode                          Replay mode

  Browser/App ──> Proxy ──> Real API        Browser/App ──> Proxy ──> Disk
                    │                                         │
                    └──> saves to disk                        └──> serves saved responses
Enter fullscreen mode Exit fullscreen mode

The trick for SSR is that it's a proxy, not a browser hook. Your Next.js server fetches go through it just like browser fetches do, so it captures both. Browser-side requests get recorded separately as HAR files (the format Playwright already understands), and server-side requests land in .mock.json files. You don't think about which is which day to day, you just record and commit.

The thirty-second version of the pitch: record your tests against the real API, delete the backend, run the tests again, watch them pass. There's a short video of exactly that on the site, test-proxy-recorder.dev.

Wiring it into Next.js

The only genuinely Next-specific part is letting the proxy know which test a server-side fetch belongs to. The browser request carries a header that ties it to the running test; you forward that same header on your SSR fetches. A tiny middleware does it for you:

// proxy.ts  (Next.js 16 middleware convention)
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); // no-op in production
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode

That's a no-op in production, so it's safe to leave in. On Next.js 15 and earlier the file is middleware.ts with the function named middleware, otherwise identical.

Then point your API base URL env var at the proxy in dev and test only, and write a normal Playwright test:

import { test, expect } from '@playwright/test';
import { playwrightProxy } from 'test-proxy-recorder';

// Flip to 'record' to hit the real API and refresh recordings.
const MODE = 'replay' as const;

test.beforeEach(async ({ page }, testInfo) => {
  await playwrightProxy.before(page, testInfo, MODE, { url: /localhost:8100/ });
});

test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByText('Welcome')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Record once with MODE = 'record', switch it back to 'replay', commit the e2e/recordings/ folder, and CI never touches the network again. If the setup above looks like a few too many moving parts, npx test-proxy-recorder init scaffolds the config, the fixture, the teardown, and the package scripts in one shot.

The parts that were actually hard

The recording loop is the easy 80%. The interesting problems are the reasons people abandon record-replay tools, so most of my time went there.

Secrets. A record-replay tool quietly tells you to commit real API responses to git, which means it's one missed review away from committing a live token. So redaction is on by default now. Authorization, Cookie, and Set-Cookie get stripped before anything hits disk, and you can add your own header names or body regexes for API keys. It's safe because replay matching ignores those headers anyway, so scrubbing them never breaks playback. For login itself, the recommended pattern keeps the whole auth flow out of recordings: run it in a Playwright setup project with the proxy in transparent mode and persist storageState to a gitignored file. There's a working example against a real AWS Cognito pool in the repo.

WebSockets. This one surprised me. Recording a socket is fine, but how do you replay it? Real time? Then a test that watches a price ticker takes as long as the recording did. So the default replays server messages as a burst on connect, which is fast and deterministic, since tests usually assert on the state that arrived, not the timing. If you do care about timing, --ws-timing original re-paces the messages from their recorded timestamps. I validated it against Binance's live BTC-USD feed: record the real stream once, replay deterministic prices on CI with no exchange account.

Non-deterministic requests. Timestamps, UUIDs, CSRF tokens in the URL. This is the classic record-replay killer, and I'll be honest, it's the area still getting work. Matching is currently method plus path plus a hash of the query string, and configurable matchers for volatile params are the next thing on the list. If your URLs are stable, you won't notice. If they're full of cache-busters, that's the rough edge today.

When this is the wrong tool

It's not a unit-test mock. If you want to assert "the UI shows an error when the API returns 500," hand-writing that one response with page.route() is faster and clearer. test-proxy-recorder earns its keep when you have real, complicated API responses you don't want to reproduce by hand, especially across SSR and browser and sockets in the same test run. It also assumes you can reach the real backend at least once to record. Fully air-gapped from day one, it can't help you.

Try it

npm install --save-dev test-proxy-recorder
npx test-proxy-recorder init
Enter fullscreen mode Exit fullscreen mode

The site is test-proxy-recorder.dev, and the repo is on GitHub with runnable examples for Next.js 16, a Chrome extension hitting X's API, the crypto ticker, and a real-auth Cognito app. MIT licensed.

If you've been fighting Playwright over SSR mocking, give it a run against your own API and tell me where it breaks. Open an issue, send a PR, or come ask in the Discord if you get stuck wiring it up. The honest edges above are exactly the kind of feedback that moves the roadmap.

Top comments (0)