We use cookies

We continuously try to improve our website and use cookies for this purpose. You can read more about what this means in our data protection policy

Mock Server with API Recording for E2E Testing with SSR

record replay

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 API

With 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 API

The 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/mockserver

Next, 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/home

automatically become:

http://localhost:1080/api.storyblok.com/v2/cdn/stories/home

The 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 API

This 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.

👉 To the Github Repo

Written by
Raphael Wirth

Technology|Februar 2026

More Articles