End-to-End test with Nextjs, Playwright and MSW
Table of Contents
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
- Split projects (auth setup, auth tests, public tests)
projects: [ { name: 'setup-auth', testDir: 'e2e/setup/auth' }, { name: 'auth-chromium', testDir: 'e2e/auth', dependencies: ['setup-auth'] }, { name: 'public-chromium', testDir: 'e2e/public' },];- Mock the RPC boundary (not server actions)
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', () => ) );});- Register handlers before navigation / SSR
// within a test handlermsw.use(/* handlers */);await page.goto('/connect/github?code=...');- Enable fetchLoopback
test.use({ nextOptions: { fetchLoopback: true } });- Use bypass, not passthrough
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 stateauth-*: authenticated tests that consume storage statepublic-*: 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.
// 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.
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:
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.
const data = await client.getUserInfo({});msw.use( http.post('https://cloud.safedep.io/safedep.services.controltower.v1.UserService/GetUserInfo', () => ));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.”
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.
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: enablefetchLoopback: truein 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: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:
- Add a folder (for example,
e2e/custom). - Add a project entry in Playwright config.
- 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.
fetchLoopbackmakes server-side mocking usable in practice.- Prefer
bypassto 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.
- nextjs
- nextjs 16
- playwright
- msw
- e2e
- end-2-end
- end-to-end
- testing
- mocking
Author
Arunanshu Biswas
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Agent Skills Threat Model
Discover critical security threats in Agent Skills - Anthropic's open format for AI agent capabilities. Learn about supply chain attacks, deferred code execution, prompt injection, and multiple...

DarkGPT: Malicious Visual Studio Code Extension Targeting Developers
Malicious extensions are lurking in the Visual Studio Code marketplace. In this case, we discover and analyze DarkGPT, a Visual Studio Code extension that exploits DLL hijacking to load malicious...

The State of MCP Registries
Explore the architecture of the Model Context Protocol (MCP) and the state of its official registry. Learn how to consume server packages programmatically and discover the underlying challenges of...

Unpacking CVE-2025-55182: React Server Components RCE Exploit Deep Dive and SBOM-Driven Identification
A critical pre-authenticated remote code execution vulnerability (CVE-2025-55182) was disclosed in React Server Components, affecting Next.js applications using the App Router. Learn about the...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
