Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 065 / 100

Mock APIs with MSW: a React & Next.js guide for developers

One mocking layer for tests, Storybook and local dev.

Expona Admin7 min read

Mock APIs with MSW

Building React and Next.js applications often means waiting for backend teams to finish their endpoints, or scrambling to handle unpredictable API behavior during development. Mock APIs with MSW (Mock Service Worker) solves this by intercepting requests at the network level, letting you define consistent mock handlers that work across your test suite, Storybook, and local development environment. Instead of maintaining separate mocking strategies for different contexts, MSW provides a single source of truth for your API behavior.

What is MSW and why it matters

Mock Service Worker is a library that intercepts HTTP and GraphQL requests using service workers in the browser and Node.js interception in your test runners. Unlike older approaches that stub specific HTTP clients or mock individual API calls, MSW sits at the network layer—meaning any library making requests (fetch, axios, React Query, SWR) works seamlessly without code changes.

This matters because frontend teams often face a timing problem: the UI needs to be built before backend APIs exist. You could hard-code sample data, but then you're testing against unrealistic network conditions. You could wait for the backend, but that blocks your progress. MSW lets you move forward confidently by defining realistic API contracts upfront, then swapping to real endpoints when they're ready.

Intercepts requests at the network level

MSW works by creating request handlers that intercept HTTP calls before they hit the real network. In Node.js environments (Jest, Vitest), it uses interceptors; in the browser, it registers a service worker. Both approaches are transparent to your application code.

Here's a basic setup for a handler that mocks a user endpoint:

tsx
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({
      id,
      name: 'Sarah Chen',
      email: 'sarah@example.com',
      role: 'engineer',
    });
  }),
];

export const server = setupServer(...handlers);

In your test setup file (e.g., vitest.setup.ts), enable the server before tests run:

tsx
import { server } from './mocks/handlers';
import { beforeAll, afterEach, afterAll } from 'vitest';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Now any fetch or axios call to /api/users/:id returns your mock response. No test fixtures, no API stubs, no brittle mocking logic scattered across your test files. The request-response cycle works exactly as it would with a real backend.

Same handlers in Jest, Vitest and the browser

A major strength of MSW is handler reusability. The same handler file works in Jest, Vitest, Storybook, and local development. This eliminates the common trap of tests passing but stories failing, or working in dev mode but breaking in tests.

For browser environments (Storybook or local dev), create a separate setup file:

tsx
// mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

// In your Storybook preview.ts or main app entry:
if (process.env.NODE_ENV === 'development') {
  worker.start();
}

Storybook integration is particularly powerful. Define your handlers once, then all your stories automatically get consistent mock API responses:

tsx
// UserCard.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { UserCard } from './UserCard';

const meta: Meta = {
  component: UserCard,
};

export default meta;
type Story = StoryObj;

export const Default: Story = {
  args: { userId: '1' },
};

export const Loading: Story = {
  args: { userId: '1' },
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', async () => {
          await delay(5000);
          return HttpResponse.json({ /* ... */ });
        }),
      ],
    },
  },
};

Your component doesn't know whether it's talking to a real API or a mock. It just works, consistently, everywhere.

Simulate errors and slow responses

Real APIs fail, timeout, and respond slowly. Your UI should handle these gracefully. MSW makes it trivial to test these scenarios without needing a backend team to deliberately break things.

Create separate handler configurations for different states:

tsx
// mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';

export const handlers = [
  http.get('/api/users/:id', async () => {
    await delay(3000);
    return HttpResponse.json({
      id: '1',
      name: 'Sarah Chen',
      email: 'sarah@example.com',
    });
  }),

  http.post('/api/posts', () => {
    return HttpResponse.json({ error: 'Server error' }, { status: 500 });
  }),

  http.get('/api/search', () => {
    return HttpResponse.json(
      { error: 'Request timeout' },
      { status: 408 }
    );
  }),
];

// For tests that need to override behavior:
export const errorHandlers = [
  http.get('/api/users/:id', () => {
    return HttpResponse.json(
      { error: 'Not found' },
      { status: 404 }
    );
  }),
];

In your tests, use server.use() to override handlers per test:

tsx
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import { server } from '../mocks/handlers';
import { errorHandlers } from '../mocks/handlers';

describe('UserProfile', () => {
  afterEach(() => server.resetHandlers());

  it('renders user data on success', async () => {
    render();
    expect(await screen.findByText('Sarah Chen')).toBeInTheDocument();
  });

  it('shows error message when API fails', async () => {
    server.use(...errorHandlers);
    render();
    expect(await screen.findByText(/not found/i)).toBeInTheDocument();
  });

  it('shows loading state during slow requests', async () => {
    render();
    expect(screen.getByRole('status')).toHaveTextContent('Loading...');
    await waitFor(() => {
      expect(screen.getByText('Sarah Chen')).toBeInTheDocument();
    }, { timeout: 5000 });
  });
});

This approach means you're testing real user flows—not synthetic mocks that hide bugs. Your loading states, error boundaries, and retry logic are all validated against realistic scenarios.

Develop before the backend is ready

One of the biggest productivity wins with MSW is the ability to start frontend development immediately. Rather than blocking on backend API design, you and the backend team can agree on request and response shapes, then both teams work in parallel.

Example workflow:

  1. Backend team shares OpenAPI spec or JSON schema for GET /api/posts/:id
  2. Frontend team creates MSW handler matching that contract
  3. Frontend implements UI and tests with mock data
  4. When backend endpoint is ready, swap the URL (if needed) and run the same tests against real data
  5. If behavior differs, you've caught a contract mismatch early

This is especially valuable for complex features like pagination, filtering, or real-time updates. You can prototype the entire feature with MSW, validate the UX, then integrate the real backend with confidence.

For Next.js apps, you can even use MSW in API routes during local development:

tsx
// pages/api/users/[id].ts
export default async function handler(req, res) {
  const { id } = req.query;
  // During dev with MSW active, this fetch uses the mock
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  res.status(200).json(data);
}

Common mistakes

Forgetting to reset handlers between tests. If you use server.use() to override a handler, always call server.resetHandlers() in your afterEach. Otherwise, one test's overrides leak into the next test and cause confusing failures.

Keeping mocks too tightly coupled to implementation. Avoid mocking at the exact URL or parameter shape your current code uses. Instead, define mocks based on the actual API contract. If your API changes, update the mock; don't work around it in tests.

Not testing with real data eventually. MSW is a development tool, not a replacement for integration tests against a staging environment. Always validate your UI against real backend responses before shipping. Mock data can hide serialization issues, timing bugs, or data inconsistencies.

Neglecting edge cases in mock responses. It's easy to mock the happy path and forget null values, empty arrays, or deeply nested structures. Be as realistic with your mocks as the actual backend data would be.

Key takeaways

  • MSW intercepts requests at the network layer, so any HTTP client (fetch, axios, React Query, SWR) works without code changes.
  • Define handlers once and reuse them across Jest, Vitest, Storybook, and local development—no duplicate mocking logic.
  • Override handlers per test to simulate errors, slow responses, and edge cases, ensuring your UI handles real-world failure modes.
  • Start frontend development immediately while backend APIs are being built, using shared API contracts to keep both teams aligned.
  • Always reset handlers between tests and validate against real data in staging to catch contract mismatches early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles