12k
All articles

Mocking Page Navigations and Forms with MSW 3

Mock page navigations and native form submissions with MSW 3 using http handlers. See HTML responses, formData parsing, and key upgrade changes.

OpenReplay Team
OpenReplay Team
Mocking Page Navigations and Forms with MSW 3

Mock Service Worker (MSW) 3.0.0 came out on 28 September 2026 and added support for intercepting page navigations and form submissions (#2721), which means a link click or a plain HTML form post can now get a mocked response with the same http handlers you already write.

Until this release, a native form submit in a Storybook or Playwright setup got past MSW completely. You either needed a real backend running for that one flow or you skipped the test. In a 2023 discussion, the maintainer said MSW skipped navigation requests on purpose, to stop a mock from being served in place of a real HTML page by mistake.

This article covers what the change makes testable, the v3 breaking changes you will run into first, and handler patterns for pages and form posts. Patch release 3.0.3 followed on 10 October 2026. For installing MSW and creating your first handlers, see mocking API servers with Mock Service Worker.

Key Takeaways

  • MSW 3.0.0 (28 September 2026) adds interception of page navigations and form submissions, but no dedicated API for it is documented, so you use the standard http.get and http.post handlers.
  • A plain HTML form sends its fields URL-encoded, not as JSON, so read the body with request.formData() instead of request.json().
  • MSW 3 is ESM-only and requires Node.js 22 or later and TypeScript 5.9 or later.
  • onUnhandledRequest is renamed onUnhandledFrame, and request.headers.get('cookie') now returns null inside handlers.

What Does MSW 3 Navigation and Form Mocking Make Possible?

With MSW 3 you can test a server-rendered form or a link to another page without running a backend. The handler returns the HTML document for the next page, and the browser renders it as it would a real server response.

Before version 3, MSW only covered requests your JavaScript made with fetch or XHR. That left out a progressively enhanced signup form, a search form that reloads the page, and a “view details” link to a server-rendered page. Now you can describe each of those as a handler and assert on what the user ends up seeing.

The error path benefits most. Session replays of form flows often show users landing on a blank or raw error page after they press submit. A mocked POST that returns validation-error HTML or a 500 lets you reproduce that screen in a test before release.

Upgrading to MSW 3: Breaking Changes That Hit First

Most suites break first on the runtime floors and a single renamed option. These are the changes in the v3.0.0 release notes to check before you look at the new features:

  • MSW is now ESM-only (#2765).
  • The minimum supported Node.js version is v22 (#2729).
  • TypeScript 5.9 is now the minimum version.
  • graphql is now exported as msw/graphql (#2691), and GraphQL is now a proper optional peer dependency. GraphQL handlers are also defined on a graphql.link() now, because graphql.query() and graphql.mutation() were removed from the namespace.
  • The onUnhandledRequest option has been renamed to onUnhandledFrame.
  • worker.stop() returns a Promise (#2724). You only need to await it in critical scenarios with requests still in flight.
  • msw/native is removed in favour of @msw/react-native. A GitHub issue reports that @msw/react-native is not yet published on npm and its source still targets MSW 2, so React Native projects have no working upgrade path until it ships.

The release notes and the 2.x → 3.x migration guide cover everything else. The snippets in this article use the new msw/http entrypoint. According to the MSW 3.0 announcement, root-level msw exports stay for backward compatibility, but the next major release will remove anything you can import from a designated entrypoint.

How Do You Mock a Page Navigation with http.get?

To mock a page navigation, write an http.get handler for the page path and return a Response that has an HTML body and a text/html content type. The v3 release notes announce navigation and form interception, but neither they nor the migration guide document a dedicated API or setup step. The examples below therefore use only the standard handlers.

Each MSW request handler combines a predicate, which filters the incoming requests, with a response resolver, which handles the ones that pass. The request interception docs note that relative predicates such as /signup resolve against location.href. A resolver that returns a Response instance makes that instance the mocked response, as the request handling docs describe. That is why a plain new Response works here without any HTML-specific helper.

import { http } from 'msw/http'

export const html = (body: string, status = 200) =>
  new Response(`<!doctype html><html><body>${body}</body></html>`, {
    status,
    headers: { 'Content-Type': 'text/html' },
  })

export const pageHandlers = [
  http.get('/signup', () =>
    html(`<form method="post" action="/signup">
            <input name="email" type="email">
            <button>Sign up</button>
          </form>`),
  ),
]

Search forms that use method="get" behave differently. A form submitted with method="get" puts its fields in the query string. MSW predicates must not contain query parameters, so match on the path alone and read the fields from new URL(request.url).searchParams:

http.get('/search', ({ request }) => {
  const q = new URL(request.url).searchParams.get('q') ?? ''
  const safe = q.replace(/&/g, '&amp;').replace(/</g, '&lt;')
  return html(`<h1>Results for ${safe}</h1>`)
})

How Do You Mock a Form Post with http.post?

A native form post is handled by an http.post handler for the form’s action URL. The resolver receives request as a standard Fetch API Request, together with params and cookies, and whatever Response it returns becomes the page the browser shows next.

The only difference from the MSW 2 pattern you already know is the body format. When your own JavaScript submits a form, it usually sends JSON:

import { http, HttpResponse } from 'msw/http'

http.post<never, { email: string }>('/api/signup', async ({ request }) => {
  const body = await request.clone().json()
  return HttpResponse.json({ ok: true, email: body.email }, { status: 201 })
})

A plain HTML form sends its fields as URL-encoded form data rather than JSON, because application/x-www-form-urlencoded is the default form encoding. Calling .json() on that body fails. Read it with Request.formData() instead. The MSW file uploads guide shows formData() used on intercepted requests.

import { http } from 'msw/http'
import { html } from './pages'

export const formHandlers = [
  http.post('/signup', async ({ request }) => {
    const form = await request.clone().formData()
    const email = form.get('email')

    if (!email) {
      return html('<p role="alert">Email is required.</p>', 422)
    }

    return html('<h1>Check your inbox</h1>')
  }),
]

Return a separate branch for each server outcome you need to test: success, validation failure, and a 500 page. Your assertions then cover the document the user actually sees.

Form flows often depend on session cookies. In v3, request.headers.get('cookie') no longer returns a mocked cookie string inside handlers. It returns null in all environments, so use the cookies resolver argument for any cookie-based logic.

Where Does MSW 3 Navigation and Form Interception Run?

The v3 release notes don’t say which environments support navigation and form interception, so check PR #2721 and the docs against your own setup before you rely on it. A page navigation is something a browser carries out, which makes browser-based runners the natural place to try these handlers.

MSW’s history explains why. Before version 3, the worker script skipped requests whose mode was navigate, as the maintainer explained in Discussion #1578. If your Vitest or Jest suite runs component tests in Node, as in mocking API calls in Vue tests with Vitest, the fetch mocks keep working as before. Don’t assume a native form submit behaves the same way there. The release also adds a Vite plugin, msw/vite (#2781), which serves the worker script for you, so you no longer copy mockServiceWorker.js into your public directory.

Wrapping Up

MSW 3 needs no new vocabulary for page and form mocking. You use http.get for the page, http.post for the submit, formData() for the body, and a Response with text/html for the result. Upgrade in this order: move to Node.js 22, TypeScript 5.9 and ESM, rename onUnhandledRequest to onUnhandledFrame, and switch imports to msw/http. After that, add a handler for each outcome of your most important form, starting with the error page.

FAQs

Does request.formData() work for forms that use enctype multipart/form-data?

Yes. Request.formData() parses both application/x-www-form-urlencoded and multipart/form-data bodies into a FormData object, so the same handler code works whether or not the form includes a file input. Text fields come back as strings from form.get(), and file inputs come back as File objects whose name, size and type you can inspect.

How do I read checkbox groups or multi-select fields in a form handler?

Use form.getAll('name') instead of form.get('name'). A checkbox group or a select with the multiple attribute sends one entry per selected value under the same field name, and get() returns only the first. getAll() returns an array of every value, or an empty array when nothing is selected, because the browser does not send unchecked checkboxes at all.

What is the difference between passthrough() and returning nothing from an MSW resolver?

Returning passthrough(), which MSW 3 imports from msw/utils/passthrough, sends the request to the real network unchanged and marks it as handled, so no later handler can affect it. Returning nothing leaves the request alone, and MSW tries the next handler that matches. Use passthrough() when you want a specific page or form submission to reach a real server on purpose.

Do I need to regenerate mockServiceWorker.js after upgrading to MSW 3?

Yes, unless you use the new msw/vite plugin, which serves the worker script for you. Otherwise, after installing MSW 3, run npx msw init followed by your public directory and commit the new file, so the worker script matches the 3.x package instead of the old 2.x copy.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.