How to Use MSW and Zod for Contract Testing in Next.js

Learn how MSW and Zod enable contract testing in Next.js to catch API schema mismatches early and build safer frontend tests.

How to Use MSW and Zod for Contract Testing in Next.js

If you’ve ever spent a week chasing a bug that turned out to be a stale mock file—or waited twenty minutes for a failing CI test only to discover the backend had changed its response shape days ago—you know the pain I’m talking about. Traditional mocking feels like building a house on sand. You write beautiful frontend code against pretend data, then ship it and pray the real API behaves the same way. It doesn’t have to be that way.

I spent the last two years working on a microservices project where every frontend team maintained its own set of JSON fixtures. Every sprint, at least one person would get burned by a schema mismatch. The breaking point came when a production incident was traced to a backend endpoint returning email as a string while my mock returned an object. We had tests, but they only proved the mock was consistent with itself, not with reality.

That’s when I started looking for a lightweight contract testing layer that could live inside our Next.js frontend repo—no external services, no complex Pact infrastructure. I wanted something that gave me type safety, runtime validation, and the ability to catch API breakage the moment a developer changed either the mock or the backend.

What I found was the combination of MSW (Mock Service Worker) and Zod. Together, they turn your mocks from fragile stand‑ins into executable contracts. By the end of this article, you’ll be able to set up the same system in your own project, and you’ll never want to go back to JSON fixtures.

Let me show you how it works.


Why MSW + Zod hits different

MSW intercepts real fetch or XMLHttpRequest calls at the network level—either inside a Service Worker in the browser or using a Node.js interceptor in tests. That means your application code never knows it’s being mocked. It just makes the same fetch calls it would make against production. And because MSW runs at the request level, it can validate both incoming payloads and outgoing responses in real time.

Zod gives you a way to define the shape of your data as a schema. But it’s more than that. Zod schemas are executable validators. You can parse a request body through a schema and throw if it doesn’t match. You can infer TypeScript types from them, so your mocks benefit from static checking at compile time. And because Zod schemas live in a shared file, they become the single source of truth for what your API should look like.

When you combine the two, you get a continuous, automated check: every mock handler can validate its output against a Zod schema, and every test that sends a request can validate that the request matches the contract. If anyone—on your team or the backend team—changes the API shape, the mismatch surfaces immediately, often before you even run the full test suite.


Setting the stage

I’ll assume you have a Next.js 14 project with TypeScript and the src/ directory. If you’re starting from scratch, you can create one with:

npx create-next-app@latest msw-zod-demo --typescript --app --src-dir
cd msw-zod-demo

Install the core libraries:

npm install msw zod
npm install -D vitest @vitejs/plugin-react @testing-library/react

Now create a folder structure that keeps contracts, mocks, and tests separate:

src/
  contracts/
    user.contract.ts
    product.contract.ts
    index.ts
  mocks/
    handlers/
      user.handlers.ts
      product.handlers.ts
    browser.ts
    server.ts
    contract-validator.ts
  tests/
    setup.ts
    user.contract.test.ts

Why this layout? Because contracts are the foundation. They should be importable by both your mock handlers and your production code (though typically you’ll only export types to production). The mocks folder isolates all MSW logic, and tests leverage the server instance.


Defining the contract with Zod

Open src/contracts/user.contract.ts. This is where the magic begins.

import { z } from 'zod'

export const CreateUserRequestSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  age: z.number().int().min(18).optional(),
})

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  age: z.number().int().nullable(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})

export const UserListResponseSchema = z.object({
  data: z.array(UserSchema),
  pagination: z.object({
    total: z.number().int(),
    page: z.number().int(),
    limit: z.number().int(),
    totalPages: z.number().int(),
    hasNextPage: z.boolean(),
  }),
})

export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>
export type User = z.infer<typeof UserSchema>
export type UserListResponse = z.infer<typeof UserListResponseSchema>

Every schema doubles as a runtime validator and a type definition. I can use userSchema.parse(data) to throw an error if the data doesn’t match, or userSchema.safeParse(data) to handle the failure gracefully. The inferred types are exactly what my handlers and tests need.


The contract-validator middleware

To make MSW enforce these contracts automatically, I write a small utility that wraps every handler’s response.

// src/mocks/contract-validator.ts
import { ZodSchema, ZodError } from 'zod'
import { http, HttpResponse } from 'msw'

export function withContractValidation<
  TRequestSchema extends ZodSchema | undefined,
  TResponseSchema extends ZodSchema
>(options: {
  requestSchema?: TRequestSchema
  responseSchema: TResponseSchema
  handler: (params: {
    request: Request
    params: Record<string, string>
  }) => Promise<z.infer<TResponseSchema>>
}) {
  return http.all(options.handler as any, async ({ request, params }) => {
    // Validate request body if schema provided
    if (options.requestSchema) {
      const body = await request.json()
      const result = options.requestSchema.safeParse(body)
      if (!result.success) {
        return HttpResponse.json(
          { error: { code: 'VALIDATION_ERROR', message: 'Request does not match contract', details: result.error.flatten().fieldErrors } },
          { status: 400 }
        )
      }
    }

    // Execute the handler
    const data = await options.handler({ request, params })

    // Validate response
    const responseResult = options.responseSchema.safeParse(data)
    if (!responseResult.success) {
      console.error('Mock returned invalid response:', responseResult.error.flatten())
      return HttpResponse.json(
        { error: { code: 'INTERNAL_MOCK_ERROR', message: 'Mock produced invalid data' } },
        { status: 500 }
      )
    }

    return HttpResponse.json(responseResult.data)
  })
}

Now every mock handler can use this wrapper. If the handler accidentally returns an integer for a field that should be a string, the middleware catches it and returns a 500. The test fails, and I fix the mock before anyone else depends on the wrong shape.


Writing the mock handlers

Let’s build a mock for POST /users and GET /users.

// src/mocks/handlers/user.handlers.ts
import { withContractValidation } from '../contract-validator'
import { CreateUserRequestSchema, UserListResponseSchema, UserSchema } from '@/contracts/user.contract'
import { faker } from '@faker-js/faker' // small helper, optional

export const handlers = [
  withContractValidation({
    requestSchema: CreateUserRequestSchema,
    responseSchema: UserSchema,
    handler: async ({ request }) => {
      const body = await request.json()
      // Simulate creation logic
      return {
        id: faker.string.uuid(),
        name: body.name,
        email: body.email,
        role: body.role,
        age: body.age ?? null,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      }
    },
  }),

  withContractValidation({
    responseSchema: UserListResponseSchema,
    handler: async () => {
      // Return a page of fake users
      const users = Array.from({ length: 20 }, () => ({
        id: faker.string.uuid(),
        name: faker.person.fullName(),
        email: faker.internet.email(),
        role: faker.helpers.arrayElement(['admin', 'editor', 'viewer']),
        age: faker.number.int({ min: 18, max: 80 }),
        createdAt: faker.date.past().toISOString(),
        updatedAt: faker.date.recent().toISOString(),
      }))
      return {
        data: users,
        pagination: {
          total: 100,
          page: 1,
          limit: 20,
          totalPages: 5,
          hasNextPage: true,
        },
      }
    },
  }),
]

Notice I’m using withContractValidation for both. If I ever update the UserSchema to require a new field, the handler’s response will fail validation, and the test will scream. This is the contract testing feedback loop I want.


Setting up MSW in tests

For Vitest, I create a server instance and activate it once.

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers/user.handlers'

export const server = setupServer(...handlers)
// src/tests/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest'
import { server } from '@/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

That’s it. Every test file that runs after this setup will have its fetch calls intercepted by MSW.


Writing a contract test

Now for the meat: a test that validates both the request we send and the response we receive—against the Zod contracts.

// src/tests/user.contract.test.ts
import { describe, it, expect } from 'vitest'
import { CreateUserRequestSchema, UserSchema } from '@/contracts/user.contract'

// We test the contract directly, no component needed.
describe('User API contract', () => {
  it('should create a user with valid request and receive valid response', async () => {
    const validRequest = {
      name: 'Alice',
      email: 'alice@example.com',
      role: 'editor',
    }

    // Validate before calling
    const parsedRequest = CreateUserRequestSchema.parse(validRequest)

    const response = await fetch('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(parsedRequest),
    })

    const body = await response.json()

    // Validate the response against the contract
    const parsedResponse = UserSchema.parse(body)
    expect(parsedResponse.name).toBe('Alice')
  })

  it('should reject invalid request body', async () => {
    const invalidRequest = { name: 'Bob' } // missing email and role

    const result = CreateUserRequestSchema.safeParse(invalidRequest)
    expect(result.success).toBe(false)

    // Also test the endpoint returns 400
    const response = await fetch('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(invalidRequest),
    })
    expect(response.status).toBe(400)
  })
})

This test is independent of any React component. It directly validates the contract. If the backend team later adds a required field to the CreateUserRequest, I update the schema, and the second test automatically catches any request that doesn’t include it. No need to wait for the backend to deploy.


What about browser development?

During local development, I use the browser Service Worker to simulate API calls. I put this in src/mocks/browser.ts:

import { setupWorker } from 'msw/browser'
import { handlers } from './handlers/user.handlers'

export const worker = setupWorker(...handlers)

Then in my src/app/layout.tsx, I conditionally start the worker only in development:

if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
  const { worker } = await import('@/mocks/browser')
  await worker.start()
}

Now my Next.js app runs entirely against mocks during local development. I can work offline, simulate errors, and test edge cases without touching a real server.


Contract testing beyond mock validation

You might be thinking: “Okay, but this only validates that my mock matches the contract. How does it help with the real backend?” Good question.

The real value shows up when you’ve shipped the frontend and the backend updates its API. If you keep your Zod schemas as a separate package (or at least a well-defined folder) and encourage backend consumers to use the same schemas to generate OpenAPI specs, then any breaking change becomes visible as soon as the backend’s actual responses differ from the contracts.

In practice, I run a nightly CI job that hits the real staging API and parses the responses against the same Zod schemas. If a field goes missing or a type changes, the job fails and notifies the team. That’s a true contract test: “Are you still respecting the schema we agreed upon?”

You can also use a tool like zod-to-openapi to generate a Swagger file from your Zod schemas, share that with the backend team, and have them validate their responses against the same definitions. That closes the loop entirely.


Integrating into CI/CD

If you use GitHub Actions, add a step that runs vitest with coverage. Because every mock handler validates its output, even a trivial unit test that calls the mock endpoint will catch contract violations.

name: Contract Tests
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx vitest --coverage

With this pipeline, a backend change that breaks the contract will fail the frontend build—often before the backend deploys. That’s a huge time saver.


Let’s talk about personal workflow

I’ve been using this setup for six months on a production Next.js application. Before that, I wasted hours debugging issues that boiled down to “the mock returns a number but the real API returns a string.” Now, when my test passes, I have real confidence that the frontend will work against the actual API—assuming the backend respects the same contract.

One pattern I particularly love is stateful mocks. For example, I can create an in‑memory database inside the mock handler and wire it up so that POST /users actually creates a record that GET /users/:id returns. That way my integration tests simulate real workflows without any external dependencies. And because the Zod schemas validate every interaction, I know my state transitions respect the contract.

Here’s a quick example of a stateful mock:

const users = new Map<string, User>()

const createUserHandler = withContractValidation({
  requestSchema: CreateUserRequestSchema,
  responseSchema: UserSchema,
  handler: async ({ request }) => {
    const body = await request.json()
    const user = {
      id: crypto.randomUUID(),
      name: body.name,
      email: body.email,
      role: body.role,
      age: body.age ?? null,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
    users.set(user.id, user)
    return user
  },
})

Now I can write a test that creates a user, fetches it, updates it, and deletes it—all within the mock world. And if any step returns data that violates the Zod schema, the test fails immediately.


Overcoming the fear of overhead

Some developers resist adding another library because they think it will slow down their tests or make mocking more complicated. In my experience, the opposite is true. Once you have the contract-validator middleware, writing a new mock handler is faster because you don’t have to manually check every field. You just return a JavaScript object and trust Zod to flag any problems. And because MSW uses real fetch, you’re testing your application’s actual HTTP logic, not a fake abstraction.

The initial setup takes about an hour. After that, every mock you write is self-validating. The time saved on debugging contract mismatches will pay for itself ten times over.


Moving forward

This isn’t a silver bullet. If the backend team doesn’t commit to a shared schema, you’ll still have drift. But with Zod, you at least have a first‑class representation of what you expect. And by using MSW to enforce those expectations locally, you catch issues before they hit production.

Start small: pick one endpoint, define its schema, write a simple test, and see how it feels. I predict you’ll want to apply it to every API call in your app. I know I did.

If this article helped you see a new path forward for your own API mocking struggles, I’d love to hear about it. Drop a comment below with the biggest contract mismatch you’ve ever dealt with—or just share your experience with MSW and Zod. And if you found the example code useful, hit the like button and share this with a teammate who’s still living in the world of JSON fixtures. Let’s help everyone write more robust frontend tests.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Our Network

More from our team

Explore our publications across finance, culture, tech, and beyond.

// More Articles

Similar Posts