---
title: "End-to-End test with Nextjs, Playwright and MSW"
description: "A practical Next.js 16 App Router E2E setup with Playwright and MSW that keeps server-side fetch deterministic by focusing mocking where it matters, not on server actions."
url: "https://safedep.io/blog/end-to-end-test-nextjs-msw-playwright"
author: "Arunanshu Biswas"
published: "2026-02-03"
---

# End-to-End test with Nextjs, Playwright and MSW

We wanted end-to-end tests that exercise the real user journey: public pages,
Auth0 redirects, and authenticated dashboards. App Router isn't hard, but it
does push most data fetching to the server. That changes the testing surface area:
browser-only mocks no longer touch the real calls. This post is how we made it stable,
scalable, and easy for anyone on the team to extend.

Tested with Next.js 16, Playwright 1.x, and MSW 2.x. If you are on
older versions, expect some API drift.

## TL;DR

1. Split projects (auth setup, auth tests, public tests)

```ts
// playwright.config.ts
projects: [
  { name: 'setup-auth', testDir: 'e2e/setup/auth' },
  { name: 'auth-chromium', testDir: 'e2e/auth', dependencies: ['setup-auth'] },
  { name: 'public-chromium', testDir: 'e2e/public' },
];
```

2. Mock the RPC boundary (not server actions)

```ts
// e2e/public/foo.spec.ts
import { test, http, HttpResponse } from 'next/experimental/testmode/playwright/msw';

test('...', async ({ page, msw }) => {
  // per test MSW based mocking
  msw.use(
    http.post('https://cloud.safedep.io/safedep.services.controltower.v1.UserService/GetUserInfo', () =>
      HttpResponse.json({ user: { email: 'test@safedep.io' }, access: [] })
    )
  );
});
```

3. Register handlers before navigation / SSR

```ts
// e2e/public/foo.spec.ts
// within a test handler
msw.use(/* handlers */);
await page.goto('/connect/github?code=...');
```

4. Enable fetchLoopback

```ts
// e2e/public/foo.spec.ts
test.use({ nextOptions: { fetchLoopback: true } });
```

5. Use bypass, not passthrough

```ts
// e2e/auth/onboard.spec.ts
import { bypass, http, test } from 'next/experimental/testmode/playwright/msw';

test('...', async ({ page, msw }) => {
  msw.use(http.post('.../UserService/GetUserInfo', ({ request }) => fetch(bypass(request))));
});
```

## Why our first attempts failed

If you are new to E2E in Next.js, the natural instinct is to wire Playwright and
mock browser requests. That works fine in client-heavy apps. In App Router, the
important calls happen inside server actions and server components. We kept
“mocking” and still saw the server hit the real backend, which meant local runs
and CI behaved differently. The tooling was fine; we were mocking at the wrong
boundary. Server actions aren't truly mockable in the real sense.

Once we accepted that, the answer became obvious: the server is the test
boundary. We stopped treating server actions as the thing to mock and instead
treated them as part of the app. What we mock are the RPC calls those actions
make. This aligns with our public API contracts and keeps mocks small and
deterministic.

## The architecture we landed on

Our first decision about structure, not mocks. A single Playwright project can
work for small suites, but it collapses different concerns into one place:
authentication setup, public flows, and app flows. That makes it hard to add new
tests without stepping on existing ones. We solved this by splitting the suite
into projects with explicit dependencies.

- `setup-auth`: performs login once and writes storage state
- `auth-*`: authenticated tests that consume storage state
- `public-*`: unauthenticated tests that must remain clean

This keeps state explicit and makes it trivial to add a new project later. If
tomorrow we need a "custom" project with its own setup, we add a folder and a
config entry, nothing else. That's the "low-friction add new tests" promise in
practice.

```ts
// playwright.config.ts (conceptually)
projects: [
  { name: 'setup-auth', testDir: 'e2e/setup/auth' },
  { name: 'auth-chromium', testDir: 'e2e/auth', dependencies: ['setup-auth'] },
  { name: 'public-chromium', testDir: 'e2e/public' },
  // future: { name: "custom", testDir: "e2e/custom", dependencies: ["setup-custom"] }
];
```

## Where the msw fixture comes from

We use Next.js experimental testmode (`next experimental-test`, or the usual
`playwright test`) and its Playwright integration. It extends the Playwright
`test` object with an `msw` fixture so tests can register handlers without
global setup.

```ts
// e2e/public/foo.spec.ts
import { test, http, HttpResponse } from 'next/experimental/testmode/playwright/msw';

test('example', async ({ page, msw }) => {
  msw.use(http.post('.../GetUserInfo', () => HttpResponse.json({})));
  await page.goto('/');
});
```

Testmode relies on the Next test proxy. In our app we enable it via
`next.config.ts` conditionally when `NEXT_PUBLIC_E2E_MODE` environment variable
is set:

```ts
export const nextConfig: NextConfig = {
  experimental: {
    // required to support e2e tests and MSW based testing
    testProxy: env.NEXT_PUBLIC_E2E_MODE || undefined,
  },
};
```

## Mocking where it actually matters

One thing to note about plumbing: Next.js testmode proxies server-side fetch
calls through the Playwright fixture. That's how MSW can intercept server-side
requests.

If server actions are real, then what do we mock? The answer is the RPC
boundary. Our server actions call public Connect/gRPC endpoints generated from
buf.build contracts. Mocking those endpoints keeps our tests deterministic
without duplicating application logic. It also makes it obvious which inputs and
outputs are "real" versus controlled by test data.

```ts
// app/.../actions.ts
const data = await client.getUserInfo({});
```

```ts
// e2e/public/connect-github-app.spec.ts
msw.use(
  http.post('https://cloud.safedep.io/safedep.services.controltower.v1.UserService/GetUserInfo', () =>
    HttpResponse.json({ user: { email: 'test@safedep.io' }, access: [] })
  )
);
```

## fetchLoopback: the missing piece for server-side mocking

Even with MSW wired in, server-side fetch calls can still fail in testmode. The
default behavior is harsh: if no handler matches, the proxy aborts the request.
That's why tests may fail in CI with errors like
`oauth_code_verification_failed` even when the flow works locally.

`fetchLoopback` is the escape hatch. With `fetchLoopback: true`, unhandled
requests fall through to a real `fetch(req.clone())` instead of being aborted.
In a server-heavy app, that's the difference between “mock everything or fail”
and “mock only what matters.”

```ts
// e2e/public/foo.spec.ts
test.use({ nextOptions: { fetchLoopback: true } });
```

This API is experimental. We treat it as infrastructure and keep its surface
area small, so if it changes we only update a handful of tests.

## Why we moved from passthrough to bypass

The next problem was how to let some requests hit the real backend while the
rest stay mocked. In theory, MSW passthrough should do this. In practice,
passthrough caused instability in testmode (including Request reuse errors). The
pattern that worked consistently was `bypass`, which routes a request to the
real network without breaking the proxy pipeline.

```ts
// e2e/auth/onboard.spec.ts
msw.use(http.post('.../UserService/GetUserInfo', ({ request }) => fetch(bypass(request))));
```

## Bugs we hit (and how we fixed them)

The easiest way to learn the rules is to break them. We did.

- **OAuth code verification failed in CI**

  The auth setup flow intermittently redirected to an error page in CI. That
  turned out to be unhandled server-side requests being aborted by the testmode
  proxy.  
  Fix: enable `fetchLoopback: true` in the auth setup project.

- **ReturnTo dropped between parallel runs**

  Public tests were occasionally redirected to `/` instead of their intended
  path. The root cause was shared middleware state leaking across requests.

  Fix: per-request middleware storage to avoid shared flags in memory. Mentioned
  here for completeness, but it's unrelated to MSW or Playwright.

  If you are using `@rescale/nemo`, set storage like so:

  ```ts
  export const proxy = createNEMO(
    {
      // ...
    },
    {
      // ...
    },
    {
      // Ensure per-request storage so middleware flags don't leak across requests.
      storage: () => new MemoryStorageAdapter(),
    }
  );
  ```

- **SSR hitting the real backend before mocks**

  Some SSR requests ran before our handlers were registered, so they hit real
  services in CI.  
  Fix: register MSW handlers before any navigation.

None of these fixes were huge, but together they defined the “rules of the road”
for a stable suite.

## Adding a new project is intentionally boring

If we want a new test type:

1. Add a folder (for example, `e2e/custom`).
2. Add a project entry in Playwright config.
3. Optionally add a setup project if it needs its own state.

Everything else stays untouched. That was the point.

## Takeaways

- Treat the server as the test boundary in App Router apps.
- Mock RPC boundaries, not server actions.
- Register MSW handlers before navigation/SSR.
- `fetchLoopback` makes server-side mocking usable in practice.
- Prefer `bypass` to passthrough in Next testmode.
- Keep experimental tooling isolated and swappable.

If you are building a server-heavy Next.js app, the biggest mindset shift is
this: your “frontend tests” are actually server tests. Once you accept that, the
tooling falls into place.
