Guide to Mocking API with MSW in NextJS

Introduction

MSW (Mock Service Worker) is the most popular library for modern projects, used to simulate APIs by intercepting network requests at the network layer. Instead of mocking at the application layer, MSW operates at the Service Worker layer (browser) and Node.js interception (server-side).


Advantages:

  • Simulate everything: Method, URL, Header, Status code, Delay.
  • Help Frontend develop independently when Backend has not finished the API.
  • Runs on both the browser (Browser) and NodeJS environment (SSR).
  • Does not change the API call logic code, only needs request interception configuration.
  • Clean mock code, written like writing a real API.

Usage: You define handlers. When the application calls a URL, MSW catches that request and returns the response you defined without ever actually sending the request to the server.

Detail

Run the following commands to install and create the mockServiceWorker.js file

# install
yarn add msw

# create mockServiceWorker.js
npx msw init public/ --save


Add the following values ​​to the .env file

NEXT_PUBLIC_ENV = dev
NEXT_PUBLIC_SERVER_HOST = http://localhost:4000

Because this is just a simulation, it only needs to run on the dev environment, you should configure it like this to easily disable it when deploying to production


Create file mock/handlers.ts

import { delay, http, HttpResponse } from "msw";

const API_URL = `${process.env.NEXT_PUBLIC_SERVER_HOST}/api`;

let products = [
{ id: "1", name: "Item 1", price: 2500, category: "Laptop", stock: 10 },
{ id: "2", name: "Item 2", price: 1200, category: "Phone", stock: 25 },
{
id: "3",
name: "Item 3",
price: 350,
category: "Accessories",
stock: 50,
},
];

export const handlers = [
http.get(`${API_URL}/products`, async () => {
console.log("--- MSW Intercepted: GET /products ---");
await delay(5000);
return HttpResponse.json(products);
}),

http.post(`${API_URL}/products`, async ({ request }) => {
const newProduct: any = await request.json();
const productWithId = {
...newProduct,
id: Math.random().toString(36).substr(2, 9),
};
products.push(productWithId);
return HttpResponse.json(productWithId, { status: 201 });
}),

http.put(`${API_URL}/products/:id`, async ({ params, request }) => {
const { id } = params;
const updatedData: any = await request.json();
products = products.map(p => (p.id === id ? { ...p, ...updatedData } : p));
return HttpResponse.json(updatedData);
}),

http.delete(`${API_URL}/products/:id`, ({ params }) => {
const { id } = params;
products = products.filter(p => p.id !== id);
return new HttpResponse(null, { status: 204 });
}),
];

  • This code defines "handlers" to handle HTTP requests (GET, POST, PUT, DELETE). It uses a `products` array as a mock database in memory to perform CRUD operations (Create, Read, Update, Delete) like a real API, along with a `delay` effect to simulate network latency.
  • After starting the project, you can view the logs on NextJS to check that the /products api will be called on the server first and then return data to the client


Create file mock/browser.ts

import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

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


Create file mock/node.ts

import { setupServer } from "msw/node";
import { handlers } from "./handlers";

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


Create file mock/MSWProvider.tsx

"use client";

import { useEffect, useState } from "react";

export const MSWProvider = ({ children }: { children: React.ReactNode }) => {
const [mswReady, setMswReady] = useState(false);

useEffect(() => {
const init = async () => {
if (process.env.NEXT_PUBLIC_ENV === "dev") {
const { worker } = await import("./browser");
await worker.start({ onUnhandledRequest: "bypass" });
setMswReady(true);
} else {
setMswReady(true);
}
};
init();
}, []);

if (!mswReady) return null;
return <>{children}</>;
};

  • This is the Provider component used to initialize MSW on the browser. It checks if the environment is `dev` then activates the Service Worker to intercept requests from the client-side. The `mswReady` state ensures the application only renders when MSW is ready to operate.
  • onUnhandledRequest: 'bypass' helps real requests (not mocked) still run normally


Create file provider/react-query.tsx

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function ReactQueryProviders({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
}),
);

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

  • Initialize QueryClient in useState to ensure each instance is unique and not re-initialized every time the component re-renders.
  • staleTime: 60 * 1000, data is considered fresh for 1 minute


Update file app/layout.tsx

import { MSWProvider } from "@/mock/MSWProvider";
import { server } from "@/mock/node";
import { ReactQueryProviders } from "@/provider/react-query";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

if (process.env.NEXT_PUBLIC_ENV === "dev") {
if (process.env.NEXT_RUNTIME === "nodejs") {
server.listen({ onUnhandledRequest: "bypass" });
}
}

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full font-sans">
<main>
<MSWProvider>
<ReactQueryProviders>
<AntdRegistry>{children}</AntdRegistry>
</ReactQueryProviders>
</MSWProvider>
</main>
</body>
</html>
);
}

  • This is the main layout file of NextJS. Here, we activate MSW Server for the backend (SSR) via `server.listen()`. At the same time, the Providers (MSW, React Query) are wrapped around the website content to provide corresponding features for the entire application.
  • server.listen() only runs once on the NodeJS side
  • We check NEXT_RUNTIME is a special environment variable to make sure not to call it wrong in Edge Runtime or Client side


Create file app/msw/actions.ts

export async function getProducts() {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_HOST}/api/products`, {
cache: "no-store",
});
if (!res.ok) return [];
return res.json();
}


Create file app/msw/page.tsx

import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { ProductPage } from "./ProductPage";
import { getProducts } from "./actions";

export default async function Page() {
const queryClient = new QueryClient();

await queryClient.prefetchQuery({
queryKey: ["products"],
queryFn: getProducts,
});

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductPage />
</HydrationBoundary>
);
}

This file acts as a Server Component. It initializes a temporary `QueryClient` to prefetch data on the server. It then uses `HydrationBoundary` to "pass" this fetched data down to the Client Component, helping the website display data immediately without waiting for the client to fetch again.


Create file app/msw/ProductPage.tsx

"use client";

import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Space, Table, Tag } from "antd";
import { useState } from "react";

interface Product {
id: string;
name: string;
price: number;
category: string;
stock: number;
}

const API_URL = `${process.env.NEXT_PUBLIC_SERVER_HOST}/api/products`;

export const ProductPage = () => {
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [form] = Form.useForm();

const { data: products = [], isLoading } = useQuery<Product[]>({
queryKey: ["products"],
queryFn: async () => {
const res = await fetch(API_URL);
return res.json();
},
staleTime: 1000 * 60,
});

const upsertMutation = useMutation({
mutationFn: async (values: Partial<Product>) => {
const isEdit = !!editingProduct;
const url = isEdit ? `${API_URL}/${editingProduct.id}` : API_URL;
const method = isEdit ? "PUT" : "POST";

const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error("Failed to save product");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
message.success(editingProduct ? "Updated successfully" : "Created successfully");
handleCloseModal();
},
onError: () => message.error("An error occurred while saving"),
});

const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`${API_URL}/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
message.success("Product deleted");
},
onError: () => message.error("Delete failed"),
});

const handleCloseModal = () => {
setIsModalOpen(false);
setEditingProduct(null);
form.resetFields();
};

const openEditModal = (product: Product) => {
setEditingProduct(product);
form.setFieldsValue(product);
setIsModalOpen(true);
};

const columns = [
{
title: "Product Name",
dataIndex: "name",
key: "name",
className: "font-semibold",
},
{
title: "Category",
dataIndex: "category",
key: "category",
render: (cat: string) => <Tag color="blue">{cat.toUpperCase()}</Tag>,
},
{
title: "Price",
dataIndex: "price",
key: "price",
render: (p: number) => <span>{p.toLocaleString()}</span>,
},
{ title: "Stock", dataIndex: "stock", key: "stock" },
{
title: "Action",
key: "action",
render: (_: any, record: Product) => (
<Space size="middle">
<Button icon={<EditOutlined />} onClick={() => openEditModal(record)} />
<Popconfirm title="Delete this product?" onConfirm={() => deleteMutation.mutate(record.id)} okButtonProps={{ loading: deleteMutation.isPending }}>
<Button danger icon={<DeleteOutlined />} loading={deleteMutation.isPending} />
</Popconfirm>
</Space>
),
},
];

return (
<div className="p-8 bg-gray-50 min-h-screen">
<div className="max-w-6xl mx-auto bg-white p-6 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">Product Management</h1>
<Button type="primary" icon={<PlusOutlined />} className="bg-blue-600" onClick={() => setIsModalOpen(true)}>
Add Product
</Button>
</div>

<Table columns={columns} dataSource={products} rowKey="id" loading={isLoading} pagination={{ pageSize: 5 }} />

<Modal
title={editingProduct ? "Edit Product" : "Add New Product"}
open={isModalOpen}
onCancel={handleCloseModal}
onOk={() => form.submit()}
confirmLoading={upsertMutation.isPending}
okText="Save"
>
<Form form={form} layout="vertical" onFinish={values => upsertMutation.mutate(values)} className="mt-4">
<Form.Item name="name" label="Product Name" rules={[{ required: true }]}>
<Input placeholder="Item" />
</Form.Item>
<div className="grid grid-cols-3 gap-4">
<Form.Item name="category" label="Category" rules={[{ required: true }]}>
<Input placeholder="Category" />
</Form.Item>
<Form.Item name="price" label="Price" rules={[{ required: true }]}>
<InputNumber className="w-full" min={0} />
</Form.Item>
<Form.Item name="stock" label="Stock Quantity" rules={[{ required: true }]}>
<InputNumber className="w-full" min={0} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
</div>
);
};

This is a complete product management interface, it uses `useQuery` to display the product list (receiving data from the previous server-side cache) and `useMutation` to handle adding, editing, and deleting products. All actions are intercepted by MSW and processed locally without the need for a real API.


The result when you view API information in the Network tab will show a notification from service worker

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

Sitemap

DevOps Practice Series