json-server vs MSW vs MirageJS: API Mocking for Development 2026
json-server vs MSW vs MirageJS: API Mocking for Development 2026
TL;DR
Mocking APIs during development and testing is a fundamental developer workflow — working offline, building before the backend is ready, running reproducible tests. Three tools dominate this space for JavaScript developers. json-server is the simplest option — point it at a db.json file, get a full REST API instantly (GET, POST, PUT, PATCH, DELETE) with filtering, pagination, and relationships, no code required; perfect for rapid prototyping. MSW (Mock Service Worker) intercepts real HTTP requests using Service Workers in the browser and Node.js interceptors — your app code makes real fetch calls, MSW intercepts before they leave the process, making it the gold standard for testing with @testing-library/react and Storybook. MirageJS runs a simulated server inside your JavaScript runtime, defines models and routes, and serializes responses — it's more sophisticated than json-server for complex data relationships but more coupled to your app than MSW. For instant mock REST API during prototyping: json-server. For test-safe, request-intercepting mocks that don't change your app code: MSW. For a full fake server with models, relationships, and factories in complex SPAs: MirageJS.
Key Takeaways
- json-server needs no code —
json-server --watch db.jsongives you a full REST API - MSW intercepts at the network layer — your app code makes real requests, MSW catches them
- MSW works in both browser and Node — Service Worker in browser, Node interceptor in tests
- MirageJS runs in-process — no separate server, all mocks live in JavaScript
- MSW is the testing standard — works seamlessly with Vitest, Jest, Testing Library
- json-server supports JSON:API — relationships via
_embedand_expandquery params - MirageJS has factories —
server.createList("post", 10)generates fixture data
Architecture Comparison
json-server Separate process, real HTTP server on a port
MSW Network layer interceptor (SW in browser, Node interceptor in tests)
MirageJS In-process JavaScript fake server, no real HTTP requests
json-server: Zero-Code REST API
json-server turns a JSON file into a complete RESTful API in seconds — no JavaScript required.
Installation and Quick Start
npm install -g json-server
# Or as a dev dependency:
npm install --save-dev json-server
// db.json
{
"posts": [
{ "id": 1, "title": "Hello World", "authorId": 1, "status": "published" },
{ "id": 2, "title": "Getting Started", "authorId": 2, "status": "draft" }
],
"authors": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"comments": [
{ "id": 1, "postId": 1, "body": "Great post!" }
]
}
json-server --watch db.json --port 3001
Now you have a full REST API:
GET /posts→ all postsGET /posts/1→ post by IDPOST /posts→ create postPUT /posts/1→ replace postPATCH /posts/1→ update postDELETE /posts/1→ delete postGET /posts?status=published→ filterGET /posts?_page=1&_per_page=10→ paginationGET /posts?_sort=title&_order=asc→ sortGET /posts/1?_embed=comments→ embed relatedGET /posts/1?_expand=author→ expand relationship
package.json Script
{
"scripts": {
"api:mock": "json-server --watch db.json --port 3001 --delay 200"
}
}
Custom Routes
// routes.json — custom route mapping
{
"/api/v1/posts": "/posts",
"/api/v1/posts/:id": "/posts/:id",
"/api/v1/authors": "/authors",
"/blog/articles": "/posts"
}
json-server --watch db.json --routes routes.json --port 3001
Custom Middleware (json-server v1.x Programmatic API)
// server.js
const jsonServer = require("json-server");
const server = jsonServer.create();
const router = jsonServer.router("db.json");
const middlewares = jsonServer.defaults();
// Add custom middleware
server.use(middlewares);
// Auth middleware
server.use((req, res, next) => {
if (req.headers.authorization || req.method === "GET") {
next();
} else {
res.status(401).json({ error: "Unauthorized" });
}
});
// Custom route
server.get("/api/stats", (req, res) => {
const db = router.db; // lowdb instance
const postCount = db.get("posts").size().value();
res.json({ posts: postCount, timestamp: new Date().toISOString() });
});
server.use(router);
server.listen(3001, () => {
console.log("Mock API running on http://localhost:3001");
});
MSW: Network Layer Interception
MSW intercepts fetch and XMLHttpRequest calls at the network level — your application code makes real requests, MSW intercepts them before they leave the browser or Node process.
Installation
npm install msw --save-dev
# Initialize Service Worker file:
npx msw init public/ --save
Define Handlers
// src/mocks/handlers.ts
import { http, HttpResponse, delay } from "msw";
interface Post {
id: number;
title: string;
body: string;
authorId: number;
status: "draft" | "published";
}
const posts: Post[] = [
{ id: 1, title: "Hello World", body: "First post content.", authorId: 1, status: "published" },
{ id: 2, title: "Getting Started", body: "Second post.", authorId: 2, status: "draft" },
];
export const handlers = [
// GET /api/posts
http.get("/api/posts", async ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get("status");
await delay(200); // Simulate network latency
const filtered = status
? posts.filter((p) => p.status === status)
: posts;
return HttpResponse.json(filtered);
}),
// GET /api/posts/:id
http.get("/api/posts/:id", ({ params }) => {
const id = Number(params.id);
const post = posts.find((p) => p.id === id);
if (!post) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(post);
}),
// POST /api/posts
http.post<never, Omit<Post, "id">>("/api/posts", async ({ request }) => {
const data = await request.json();
const newPost: Post = { ...data, id: posts.length + 1 };
posts.push(newPost);
return HttpResponse.json(newPost, { status: 201 });
}),
// PATCH /api/posts/:id
http.patch<{ id: string }, Partial<Post>>("/api/posts/:id", async ({ params, request }) => {
const id = Number(params.id);
const updates = await request.json();
const index = posts.findIndex((p) => p.id === id);
if (index === -1) {
return new HttpResponse(null, { status: 404 });
}
posts[index] = { ...posts[index], ...updates };
return HttpResponse.json(posts[index]);
}),
// DELETE /api/posts/:id
http.delete("/api/posts/:id", ({ params }) => {
const id = Number(params.id);
const index = posts.findIndex((p) => p.id === id);
if (index === -1) {
return new HttpResponse(null, { status: 404 });
}
posts.splice(index, 1);
return new HttpResponse(null, { status: 204 });
}),
];
Browser Setup (Service Worker)
// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
// src/main.tsx
async function enableMocking() {
if (process.env.NODE_ENV !== "development") return;
const { worker } = await import("./mocks/browser");
return worker.start({
onUnhandledRequest: "warn", // Warn when no handler matches
});
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
});
Node.js Setup (For Tests)
// src/mocks/node.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// vitest.setup.ts or jest.setup.ts
import { server } from "./src/mocks/node";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Testing with MSW
// src/components/PostList.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/node";
import { PostList } from "./PostList";
test("renders post list", async () => {
render(<PostList />);
// MSW intercepts the GET /api/posts request
await waitFor(() => {
expect(screen.getByText("Hello World")).toBeInTheDocument();
expect(screen.getByText("Getting Started")).toBeInTheDocument();
});
});
test("handles error state", async () => {
// Override handler for this test only
server.use(
http.get("/api/posts", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<PostList />);
await waitFor(() => {
expect(screen.getByText("Failed to load posts")).toBeInTheDocument();
});
});
test("shows empty state", async () => {
server.use(
http.get("/api/posts", () => {
return HttpResponse.json([]);
})
);
render(<PostList />);
await waitFor(() => {
expect(screen.getByText("No posts yet")).toBeInTheDocument();
});
});
MirageJS: In-Process Fake Server
MirageJS creates a fake server inside your JavaScript runtime — it intercepts requests at the XMLHttpRequest/fetch level and responds from an in-memory database with models and serializers.
Installation
npm install miragejs --save-dev
Server Setup
// src/mocks/server.ts
import { createServer, Model, Factory, hasMany, belongsTo } from "miragejs";
export function makeServer({ environment = "development" } = {}) {
return createServer({
environment,
models: {
post: Model.extend({
author: belongsTo(),
comments: hasMany(),
}),
author: Model.extend({
posts: hasMany(),
}),
comment: Model.extend({
post: belongsTo(),
}),
},
factories: {
post: Factory.extend({
title(i: number) {
return `Post ${i + 1}`;
},
body() {
return "Lorem ipsum dolor sit amet...";
},
status() {
return Math.random() > 0.5 ? "published" : "draft";
},
createdAt() {
return new Date().toISOString();
},
}),
author: Factory.extend({
name(i: number) {
return `Author ${i + 1}`;
},
email(i: number) {
return `author${i + 1}@example.com`;
},
}),
},
seeds(server) {
// Create 3 authors
const authors = server.createList("author", 3);
// Create 10 posts with relationships
authors.forEach((author) => {
server.createList("post", 3, { author });
});
},
routes() {
this.namespace = "api";
// GET /api/posts
this.get("/posts", (schema, request) => {
const { status } = request.queryParams;
if (status) {
return schema.where("post", { status });
}
return schema.all("post");
});
// GET /api/posts/:id
this.get("/posts/:id", (schema, request) => {
return schema.find("post", request.params.id);
});
// POST /api/posts
this.post("/posts", (schema, request) => {
const attrs = JSON.parse(request.requestBody);
return schema.create("post", attrs);
});
// PATCH /api/posts/:id
this.patch("/posts/:id", (schema, request) => {
const attrs = JSON.parse(request.requestBody);
const post = schema.find("post", request.params.id);
return post.update(attrs);
});
// DELETE /api/posts/:id
this.delete("/posts/:id", (schema, request) => {
const post = schema.find("post", request.params.id);
return post.destroy();
});
// Passthrough for everything else (e.g., auth endpoints you don't mock)
this.passthrough("https://auth.example.com/**");
// Simulated delay
this.timing = 200;
},
});
}
Start in Development
// src/main.tsx
if (process.env.NODE_ENV === "development") {
const { makeServer } = await import("./mocks/server");
makeServer();
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
Testing with MirageJS
// src/components/PostList.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { makeServer } from "../mocks/server";
import { Server } from "miragejs";
import { PostList } from "./PostList";
let server: Server;
beforeEach(() => {
server = makeServer({ environment: "test" });
});
afterEach(() => {
server.shutdown();
});
test("renders posts from server", async () => {
server.createList("post", 3, { status: "published" });
render(<PostList />);
await waitFor(() => {
expect(screen.getAllByRole("article")).toHaveLength(3);
});
});
test("handles empty state", async () => {
// No posts created — empty server
render(<PostList />);
await waitFor(() => {
expect(screen.getByText("No posts yet")).toBeInTheDocument();
});
});
Feature Comparison
| Feature | json-server | MSW | MirageJS |
|---|---|---|---|
| Setup complexity | ✅ Zero (JSON file) | Medium (handlers) | Medium-High |
| Needs separate process | ✅ Yes (HTTP server) | ❌ No | ❌ No |
| Works in tests | ⚠️ Separate process | ✅ Node interceptor | ✅ In-process |
| Browser interception | No | ✅ Service Worker | ✅ XMLHttpRequest |
| TypeScript | Limited | ✅ Full | ✅ Full |
| Data relationships | _embed/_expand | Manual | ✅ Models |
| Factories | No | No (use @faker-js) | ✅ Built-in |
| Real HTTP requests | ✅ (actual fetch) | ✅ intercepted | No (shimmed) |
| Custom responses | Via middleware | ✅ Full control | ✅ Full control |
| REST auto-generation | ✅ Full CRUD | Manual | Shortcuts available |
| GraphQL support | No | ✅ graphql.query | No |
| Storybook | External mock | ✅ Works natively | External mock |
| npm weekly | 600k | 2.5M | 250k |
| GitHub stars | 22k | 15k | 6k |
When to Use Each
Choose json-server if:
- Rapid prototyping — need a REST API in 5 minutes
- Non-technical stakeholders need to explore a working API
- Frontend team working before backend is ready
- Simple CRUD without complex test assertions
- Need a real HTTP server for testing with Postman/curl
Choose MSW if:
- Unit and integration testing with React Testing Library or Vitest
- Mocking in Storybook stories (MSW Storybook addon)
- Want to test real network paths without changing app code
- Need both browser development mocking AND test mocking from same handlers
- GraphQL mocking with
graphql.queryandgraphql.mutation - The testing standard — most projects should use MSW for testing
Choose MirageJS if:
- Complex SPA with many related models and need database-like querying
- Factories for generating test fixtures are critical
- Need to simulate relationships (posts → comments → author)
- Large application with an established Mirage server already in place
- Need full CRUD from a single server configuration without writing every handler
Methodology
Data sourced from json-server documentation (github.com/typicode/json-server), MSW documentation (mswjs.io/docs), MirageJS documentation (miragejs.com/docs), npm weekly download statistics as of February 2026, GitHub star counts as of February 2026, and community usage patterns from React Testing Library guides and OSS testing documentation.
Related: MSW vs Nock vs axios-mock-adapter API mocking for Node.js-specific HTTP mocking, or supertest vs fastify inject vs Hono API integration testing for testing real HTTP handlers.