The most surprising thing about mocking Next.js API routes with MSW is that you’re not actually mocking Next.js at all; you’re mocking the browser’s fetch API, which Next.js happens to use under the hood.

Let’s see it in action. Imagine you have a simple Next.js API route at pages/api/users/[id].js that fetches user data:

// pages/api/users/[id].js
export default function handler(req, res) {
  const { id } = req.query;
  const users = {
    1: { id: 1, name: 'Alice' },
    2: { id: 2, name: 'Bob' },
  };

  if (users[id]) {
    res.status(200).json(users[id]);
  } else {
    res.status(404).json({ message: 'User not found' });
  }
}

And in your frontend code, you fetch this user:

// components/UserProfile.js
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        setUser(data);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    }
    fetchUser();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>ID: {user.id}</p>
    </div>
  );
}

export default UserProfile;

To test UserProfile without hitting the actual Next.js API route, we’ll use MSW. First, install it:

npm install msw --save-dev
# or
yarn add msw --dev

Next, create a mocks/browser.js file:

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

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

And mocks/handlers.js:

// mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;

    const users = {
      1: { id: 1, name: 'Alice' },
      2: { id: 2, name: 'Bob' },
    };

    if (users[id]) {
      return res(ctx.status(200), ctx.json(users[id]));
    }

    return res(ctx.status(404), ctx.json({ message: 'User not found' }));
  }),
];

Now, in your test setup file (e.g., jest.setup.js or vitest.setup.ts), you’ll tell MSW to start the worker. For React Testing Library with Jest:

// jest.setup.js
import '@testing-library/jest-dom';
import { server } from './mocks/server'; // We'll create this next

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

And for Vitest:

// vitest.setup.ts
import { vi } from 'vitest';
import { server } from './mocks/server'; // We'll create this next

vi.stubGlobal('fetch', (global.fetch)); // Ensure fetch is available

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

Notice we need a server for Node.js environments (like Jest/Vitest) and a worker for browser environments. Let’s create mocks/server.js:

// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

Finally, in your test file (UserProfile.test.js):

// components/UserProfile.test.js
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import { server } from '../mocks/server';
import { rest } from 'msw';

describe('UserProfile', () => {
  it('displays user information when fetched successfully', async () => {
    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res(ctx.status(200), ctx.json({ id: 1, name: 'Alice' }));
      })
    );

    render(<UserProfile userId="1" />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('ID: 1')).toBeInTheDocument();
    });
  });

  it('displays an error message when user is not found', async () => {
    server.use(
      rest.get('/api/users/99', (req, res, ctx) => {
        return res(ctx.status(404), ctx.json({ message: 'User not found' }));
      })
    );

    render(<UserProfile userId="99" />);

    await waitFor(() => {
      expect(screen.getByText('Error: HTTP error! status: 404')).toBeInTheDocument();
    });
  });
});

When you run this test, fetch('/api/users/1') inside UserProfile.js doesn’t actually go to your Next.js development server. Instead, MSW intercepts it at the network level, looks at the request URL (/api/users/1), finds a matching handler in mocks/handlers.js or mocks/server.js, and returns the mocked response. This allows you to test your component’s UI logic in isolation, regardless of your API’s actual implementation or state.

The core of MSW’s power lies in its request interception mechanism. It leverages the Service Worker API in the browser and Node.js’s http (or https) modules to intercept outgoing requests. When a request is made, MSW checks its registered handlers. If a handler matches the request’s method and URL pattern, MSW returns the mocked response defined in that handler, preventing the actual network request from ever being sent. This is crucial for testing because it ensures your tests are deterministic, fast, and don’t depend on external services or even your own backend running.

A subtle but powerful aspect of MSW is its ability to define handlers at different scopes. You can have global handlers defined in mocks/handlers.js that apply to all tests, and then override or add specific handlers within individual test cases using server.use() or worker.use(). This allows for fine-grained control over the mocked responses for each test scenario, making it easy to simulate various API behaviors (success, error, loading states, different data payloads) without cluttering your main mock setup.

The primary mechanism for controlling MSW is through its rest and graphql modules, which provide fluent APIs for defining request handlers. You specify the HTTP method (get, post, put, delete, etc.) or GraphQL operation, the URL path (which can include parameters like :id), and then a resolver function. This resolver receives the request details and returns a res object, which you can use to set the status code, headers, and body of the mocked response using ctx (context) helpers like ctx.status(), ctx.json(), and ctx.text().

The most potent way to achieve isolation is by mocking the fetch call itself. MSW doesn’t just mock the Next.js API route; it intercepts any network request made by the fetch API (or other libraries like axios if configured). This means your Next.js API routes, when running in a test environment where MSW is active, are effectively treated as just another external API endpoint from the perspective of your frontend components. The test environment sets up a mock service worker that sits between your component and any network call, including those intended for /api/ routes.

When you’re writing tests for your Next.js components that consume API routes, you’re not testing the Next.js server-side routing or the actual Node.js handler logic. You’re testing how your React component behaves when fetch returns certain data. MSW allows you to precisely control what fetch returns for any given URL.

This approach also means you can mock API routes that don’t even exist yet in your Next.js project, allowing you to develop your UI in parallel with your backend.

The next step is understanding how to integrate MSW with Next.js’s Server Components and route handlers, which operate in a different environment than client-side components.

Want structured learning?

Take the full Nextjs course →