Mock Server with API Recording for E2E Testing with SSR

At a Glance
Have you ever wondered how to mock server-side API requests? In the browser, it’s easy. But with server-side rendering, your test browser never even sees the calls. And then there’s the issue of huge static mock data: for small code changes, you end up adjusting hundreds of lines of mock JSON. The smartive Mock Server records real API responses and replays them deterministically. This keeps your E2E tests stable — even when your app renders on the server.
Everything Was Easy in the Browser
Modern web apps almost always depend on external APIs — headless CMSs, auth services, payment providers, search, you name it. For E2E tests, that means you’re not just testing your app, but also third-party systems. And that’s exactly what makes tests unstable and unreliable.
So what do you do with external dependencies in tests? You isolate them. Test isolation isn’t new — external systems are mocked so tests remain deterministic.
In classic client-side apps, this was trivial:
Browser → external APIWith tools like Playwright, MSW, Axios interceptors, etc., you can intercept HTTP requests directly in the browser and replace them with mock responses.
test("renders homepage headline", async ({ page }) => {
await page.route(
"https://api.storyblok.com/v2/cdn/stories/home*",
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
story: {
slug: "home",
content: {
component: "page",
heading: "Mocked Headline",
},
},
}),
});
},
);
await page.goto("http://localhost:3000");
await expect(page.getByText("Mocked Headline")).toBeVisible();
});When Server-Side Rendering Enters the Chat
The problem: Modern frameworks like Next.js, Remix & co. fetch much of their data on the server. For SEO, performance, and caching reasons, data fetching moves from the browser to the app server.
The architecture then looks like this:
Browser → App Server → external APIThe key point: The HTTP call happens server-side. The test browser never sees it. Browser interceptors no longer work because the request happens outside the browser runtime. Test isolation is still the goal — but your mock is sitting in the wrong place in the system.
The Solution: A Mock Server
The idea is simple: instead of intercepting requests, you let them go out as usual — just not to the real API, but to a mock server.
Your app continues making real server-side HTTP requests. The only difference: in your test environment, the base URL no longer points to the real API e.g. https://api.storyblok.com, but to your mock server http://localhost:1080. Everything else in your production code remains unchanged.
The mock server is entirely under your control. You can configure it via HTTP and define how it should respond to specific requests.
test("renders homepage headline", async ({ page }) => {
await fetch("http://localhost:1080/mock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request: {
match: "/api.storyblok.com/v2/cdn/stories/home.*",
},
response: {
status: 200,
body: {
story: {
slug: "home",
content: {
component: "page",
heading: "Mocked Headline",
},
},
},
},
}),
});
await page.goto("http://localhost:3000");
await expect(page.getByText("Mocked Headline")).toBeVisible();
});The Problem with Classic Mock Servers
Have you ever mocked real headless CMS responses? They’re usually huge. Relations, nested components, rich text, media assets, renditions, i18n, draft/published states, SEO metadata, and more. A single page call can easily return several hundred lines of JSON.
Maintaining this mock data manually quickly becomes a nightmare:
- API changes → mocks break
- Field renamed → tests fail
- Content extended → JSON needs updating
The larger the project, the worse it gets. In short: static mocks don’t scale.
Our Approach: Mock Server as Proxy with Record & Replay
At smartive, we built a small custom mock server for exactly this reason. It can be configured via HTTP — but it can also run as a proxy between your app and the real API.
First, start it locally as a Docker container:
docker run -p 1080:1080 smartive/mockserverNext, tell your app that in the test environment it should no longer call the real API directly, but instead call the mock server. The mock server expects the original API host as part of the URL.
STORYBLOK_API_BASE_URL="http://localhost:1080/api.storyblok.com"Requests like:
https://api.storyblok.com/v2/cdn/stories/homeautomatically become:
http://localhost:1080/api.storyblok.com/v2/cdn/stories/homeThe mock server extracts the target host and proxies the request correctly to the real API. The architecture now looks like this:
Browser → App Server → smartive Mockserver → external APIThis creates three clearly separated phases.
Phase 1 – Record
Activate recording mode:
await fetch("http://localhost:1080/mock/recordings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: true }),
});From now on, the mock server acts as a proxy: it forwards requests to the real API and returns the responses unchanged — but records everything.
Now run your E2E tests once normally so all relevant requests are recorded.
Phase 2 – Export Recordings
After the initial record run, export the stored recordings:
const response = await fetch("http://localhost:1080/mock/recordings");
const recordings = await response.json();
writeFileSync(
"test/e2e/recordings/home.json",
JSON.stringify(recordings, null, 2),
);Store this JSON in your repository and version it.
Important: Depending on the API, these files can become large. Before committing, make sure no secrets are included — e.g. API keys, tokens, or other sensitive headers.
Phase 3 – Replay
In replay mode, you use the mock server like a classic mock server — except that the responses come from real, previously recorded API calls.
The server stores no persistent state. You upload the recordings again via HTTP and redefine its behavior. In CI, you start it, configure it, and feed it the versioned JSON files from your repo. From that point on, it answers all requests locally — without calling the real API.
import recordings from "../recordings/home.json";
test("renders homepage headline", async ({ page }) => {
await fetch("http://localhost:1080/mock/recordings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
active: false,
recordings,
}),
});
await page.goto("http://localhost:3000");
await expect(page.getByText("Mock Server with API Recording")).toBeVisible();
});Ready for Stable E2E Tests?
Try the mock server yourself — locally or in your CI. It’s ready to run as a Docker container in just a few seconds. The mock server is intentionally kept extremely simple. Around 300 lines of code. No magic. No hidden logic. Just as much as necessary — nothing more.
And most importantly: you no longer have to dig through thousands of lines of handwritten mock JSON when your API changes. Just record again, commit, done.
If you like, check out the code, try it yourself, or build your own small variation. The concept is simple. And that’s exactly what makes it so powerful.

Written by
Raphael Wirth





