Kit uses Mock Service Worker (MSW) to intercept HTTP requests at the network level and return mock responses. This means your tests and local development environment need no database, no external APIs, and no service accounts — MSW replaces everything with deterministic, instant responses.
The mock layer includes 12 handler files covering every API surface: AI chat with streaming (including vision/image analysis), image generation, payment subscriptions, credit system, rate limiting, file uploads, email, and more.
Architecture
MSW runs in two modes depending on the environment:
| Mode | Module | Used By | Intercept Method |
|---|---|---|---|
| Server | apps/boilerplate/src/mocks/server.ts | Vitest (Node.js) | Patches http/https modules |
| Browser | apps/boilerplate/src/mocks/browser.ts | E2E tests, dev server | Service Worker (mockServiceWorker.js) |
Both modes share the same handler files — you write handlers once and they work everywhere.
Server Mode (Vitest)
src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// This configures a request mocking server with the given request handlers.
// This server will be used in tests to intercept and mock HTTP requests.
export const server = setupServer(...handlers)
// Establish API mocking before all tests.
// This ensures that the mock server is running and ready to intercept requests.
export function startServer() {
server.listen({
onUnhandledRequest: 'warn', // Warn about unhandled requests in tests
})
}
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
export function resetServer() {
server.resetHandlers()
}
// Clean up after the tests are finished.
export function stopServer() {
server.close()
}
The server is started in
apps/boilerplate/src/test/setup.ts before all tests and closed after. Each test resets handlers to prevent state leakage.Browser Mode (E2E / Development)
src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
// This configures a Service Worker with the given request handlers.
// This worker will be used in the browser during development.
export const worker = setupWorker(...handlers)
// Start the worker
export async function startWorker() {
if (typeof window === 'undefined') {
return
}
// Only start the worker in development or test mode
// E2E tests need MSW to intercept API calls and return mocked data
if (
process.env.NODE_ENV !== 'development' &&
process.env.NODE_ENV !== 'test'
) {
return
}
// Check if MSW is enabled via environment variable
if (process.env.NEXT_PUBLIC_MSW_ENABLED !== 'true') {
console.info('MSW is disabled. Set NEXT_PUBLIC_MSW_ENABLED=true to enable.')
return
}
try {
await worker.start({
onUnhandledRequest: 'bypass', // Let unhandled requests pass through
serviceWorker: {
url: '/mockServiceWorker.js',
},
})
console.info('🔶 MSW (Mock Service Worker) started successfully')
} catch (error) {
console.error('Failed to start MSW:', error)
}
}
// Stop the worker
export function stopWorker() {
if (typeof window !== 'undefined' && worker) {
worker.stop()
}
}
Browser mode activates when:
- Environment is
developmentortest NEXT_PUBLIC_MSW_ENABLEDis set totrue
In development, MSW lets you build features against realistic API responses without running backend services. In E2E tests, it provides deterministic data for Playwright assertions.
MSW Utility Exports
src/mocks/index.ts
// Re-export all mock utilities for convenient imports
export { handlers } from './handlers'
export { server, startServer, resetServer, stopServer } from './server'
export { worker, startWorker, stopWorker } from './browser'
// Helper to check if MSW is enabled
export const isMSWEnabled = () => {
if (typeof window === 'undefined') {
// Server-side: MSW is always enabled in tests
return process.env.NODE_ENV === 'test'
}
// Client-side: Check environment variable
return process.env.NEXT_PUBLIC_MSW_ENABLED === 'true'
}
The
isMSWEnabled() helper detects the current environment: server-side checks NODE_ENV, client-side checks the NEXT_PUBLIC_MSW_ENABLED flag.Handler Inventory
All handlers are aggregated in
apps/boilerplate/src/mocks/handlers.ts and spread into a single array:src/mocks/handlers.ts — Handler Imports
import { http, HttpResponse } from 'msw'
import { isValidRequestBody } from '@/lib/email/types'
import {
lemonsqueezyHandlers,
getTierFromSubscription,
} from './lemonsqueezy-handlers'
import { aiHandlers } from './ai-handlers'
import { dashboardHandlers } from './dashboard-handlers'
import { settingsHandlers } from './settings-handlers'
import { billingHandlers } from './billing-handlers'
import { checkoutHandlers } from './checkout-handlers'
import { demoHandlers } from './demo-handlers'
import { creditHistoryHandlers } from './credit-history-handlers'
import { creditPreferencesHandlers } from './credit-preferences-handlers'
import { imageGenHandlers } from './image-gen-handlers'
src/mocks/handlers.ts — Handler Aggregation
export const handlers = [
// Include all Lemon Squeezy handlers
...lemonsqueezyHandlers,
// Include all AI handlers
...aiHandlers,
// Include all Dashboard handlers (NEW)
...dashboardHandlers,
// Include all Settings handlers (NEW)
...settingsHandlers,
// Include all Billing handlers (NEW)
...billingHandlers,
// Include all Checkout handlers (PRP: Direct Checkout)
...checkoutHandlers,
// Include all Demo handlers (Demo Mode: tier-aware mocks)
...demoHandlers,
// Include all Credit History handlers (NESAI-037)
...creditHistoryHandlers,
// Include all Credit Preferences handlers (NESAI-054)
...creditPreferencesHandlers,
// Include all Image Generation handlers (NESAI-062)
...imageGenHandlers,
Handler Files
| File | Mocked APIs | Key Features |
|---|---|---|
handlers.ts | User, posts, auth, dashboard, email, files, health, errors | Pagination, file upload validation, delayed responses |
ai-handlers.ts | AI chat, RAG bot, conversations, usage tracking | SSE streaming (OpenAI-compatible), vision-aware credit deduction (text: 1.5, image: 2.0), token estimation |
lemonsqueezy-handlers.ts | Subscriptions, products, variants, webhooks | Dynamic tier calculation, grace periods, trial handling |
dashboard-handlers.ts | Dashboard stats, analytics, activity feed | Realistic chart data, time-series responses |
settings-handlers.ts | User settings, preferences | Profile updates, preference toggles |
billing-handlers.ts | Billing history, invoices, payment methods | Subscription state management |
checkout-handlers.ts | Direct checkout, checkout sessions | Checkout URL generation, variant validation |
credit-history-handlers.ts | Credit transaction history | Paginated credit history, transaction types |
credit-preferences-handlers.ts | Credit preferences | Bonus credit auto-use preference toggle |
image-gen-handlers.ts | Image generation | GPT Image API mock with base64 responses |
rate-limit-handlers.ts | Rate limiting | Rate limit check and reset mocks |
demo-handlers.ts | All demo-mode endpoints | Tier-aware responses, credit balance integration |
Handler Patterns
Basic GET/POST Handlers
typescript
import { http, HttpResponse } from 'msw'
export const myHandlers = [
// GET — return static data
http.get('/api/posts', () => {
return HttpResponse.json({
posts: [{ id: '1', title: 'Hello World' }],
total: 1,
})
}),
// POST — inspect request body and respond
http.post('/api/posts', async ({ request }) => {
const body = await request.json()
return HttpResponse.json(
{ id: 'new-post', ...body },
{ status: 201 }
)
}),
]
Request Inspection
Access headers, query parameters, and route parameters:
typescript
http.get('/api/posts/:slug', ({ params, request }) => {
const { slug } = params
const url = new URL(request.url)
const page = url.searchParams.get('page') || '1'
const authHeader = request.headers.get('authorization')
// Use extracted values to customize response
return HttpResponse.json({ slug, page })
})
Error Simulation
Return specific status codes to test error handling:
typescript
// Permanent error endpoint for testing
http.get('/api/error/500', () => {
return new HttpResponse(null, {
status: 500,
statusText: 'Internal Server Error',
})
})
// Conditional error based on request content
http.post('/api/contact', async ({ request }) => {
const body = await request.json()
if (!body?.email) {
return HttpResponse.json(
{ error: 'Email is required' },
{ status: 400 }
)
}
return HttpResponse.json({ success: true })
})
Streaming Responses (SSE)
The AI handlers demonstrate Server-Sent Events for streaming chat responses:
typescript
http.post('/api/ai/chat', () => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Send chunks in OpenAI-compatible format
const chunks = ['Hello', ' from', ' AI', '!']
chunks.forEach((chunk, i) => {
const data = JSON.stringify({
choices: [{ delta: { content: chunk } }],
})
controller.enqueue(
encoder.encode(`data: ${data}\n\n`)
)
})
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
})
Pagination
The
handlers.ts file demonstrates pagination with query parameter extraction:typescript
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') || '1')
const limit = parseInt(url.searchParams.get('limit') || '10')
const startIndex = (page - 1) * limit
const paginatedPosts = allPosts.slice(startIndex, startIndex + limit)
return HttpResponse.json({
posts: paginatedPosts,
total: allPosts.length,
page,
totalPages: Math.ceil(allPosts.length / limit),
})
})
Tier-Aware Responses
The payment handlers calculate subscription tiers dynamically based on status, handling edge cases like grace periods and trial expiration:
typescript
http.get('/api/payments/subscription', () => {
const subscription = {
status: 'active',
variantId: 'var_pro_monthly',
currentPeriodEnd: new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000
).toISOString(),
}
// Dynamic tier calculation — handles active, trial,
// cancelled, paused, expired, past_due states
const tier = getTierFromSubscription(subscription)
return HttpResponse.json({
subscription,
tier,
hasActiveSubscription: subscription.status === 'active',
})
})
Per-Test Handler Overrides
In Vitest (server.use)
Override any handler for a single test using
server.use(). The override is automatically cleaned up by server.resetHandlers() in afterEach:typescript
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'
describe('Dashboard', () => {
it('shows empty state when no data', async () => {
// Override the dashboard handler for this test only
server.use(
http.get('/api/dashboard', () => {
return HttpResponse.json({
stats: { totalPosts: 0, totalViews: 0 },
recentActivity: [],
})
})
)
// Test now receives empty data from the overridden handler
render(<Dashboard />)
await expect(screen.getByText(/no data yet/i)).toBeInTheDocument()
})
it('shows error state on API failure', async () => {
server.use(
http.get('/api/dashboard', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
)
})
)
render(<Dashboard />)
await expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
})
})
Use
server.use() to override handlers per-test instead of modifying the shared handler files. This keeps the default happy-path handlers stable and makes each test self-contained.Always call
server.resetHandlers() in afterEach (this is already configured in apps/boilerplate/src/test/setup.ts). Without it, overrides from one test leak into subsequent tests, causing flaky failures that are hard to diagnose.In Playwright (page.route)
For E2E tests, use Playwright's
page.route() to intercept requests at the browser level:typescript
import { test, expect } from '@playwright/test'
test('shows error when subscription API fails', async ({ page }) => {
await page.route('/api/payments/subscription', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Service unavailable' }),
})
})
await page.goto('/dashboard/billing')
await expect(page.getByText(/unable to load subscription/i)).toBeVisible()
})
Adding New Handlers
1
Create the handler file
Create a new file in
apps/boilerplate/src/mocks/ following the naming convention <feature>-handlers.ts:typescript
// src/mocks/search-handlers.ts
import { http, HttpResponse } from 'msw'
export const searchHandlers = [
http.get('/api/search', ({ request }) => {
const url = new URL(request.url)
const query = url.searchParams.get('q') || ''
return HttpResponse.json({
results: [
{ id: '1', title: `Result for "${query}"`, score: 0.95 },
],
total: 1,
})
}),
]
2
Register in handlers.ts
Import and spread the new handlers into the main handler array:
typescript
// src/mocks/handlers.ts
import { searchHandlers } from './search-handlers'
export const handlers = [
...searchHandlers,
// ... existing handlers
]
3
Test with per-test overrides
Write tests that use the default handler and override for edge cases:
typescript
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'
describe('Search', () => {
it('returns results from default handler', async () => {
const res = await fetch('/api/search?q=test')
const data = await res.json()
expect(data.results).toHaveLength(1)
})
it('handles empty results', async () => {
server.use(
http.get('/api/search', () => {
return HttpResponse.json({ results: [], total: 0 })
})
)
const res = await fetch('/api/search?q=nothing')
const data = await res.json()
expect(data.results).toHaveLength(0)
})
})
MSW handlers are imported by
MSWProvider (a client component), which means they are bundled for the browser. Importing Node.js-only modules will cause production build failures.Forbidden imports in handler files:
@prisma/client— requiresfs,path,crypto@/test/factories— may import Prisma typesfs,path,crypto— Node.js built-in modules- Any server-only library
Build error signature: If you see
Module not found: Can't resolve 'fs' during pnpm build, trace the import chain from apps/boilerplate/src/mocks/handlers.ts — a handler is importing a server-only module.Solution: Define all mock types and factory functions inline within the handler file. Use
import type { ... } for type-only imports (these are erased at compile time). For database-specific test logic, use server.use() per-test overrides instead.Demo Mode Integration
Handler files integrate with the
demoCreditStore to provide realistic credit deduction during demo mode. The AI handlers check credit balance before processing requests:typescript
// In ai-handlers.ts (simplified)
http.post('/api/ai/chat', async ({ request }) => {
const { credits } = demoCreditStore.getState()
if (credits <= 0) {
return HttpResponse.json(
{ error: 'No credits remaining' },
{ status: 402 }
)
}
// Deduct credits based on estimated token usage
demoCreditStore.getState().deductCredits(10)
// Return streaming response...
})
This allows the marketing site's demo mode to show realistic credit deduction behavior without any backend services.
Environment Configuration
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_MSW_ENABLED | false | Enable MSW in the browser (set to true in apps/boilerplate/.env.test) |
DISABLE_REPOSITORY_MOCKS | false | Disable Repository layer in unit tests (set to true in apps/boilerplate/vitest.config.ts) |
NODE_ENV | — | MSW server mode activates when NODE_ENV === 'test' |
MSW Lifecycle in Tests
The MSW server lifecycle is managed in
apps/boilerplate/src/test/setup.ts:typescript
// Before all tests — start the server
beforeAll(() => {
server.listen({ onUnhandledRequest: 'warn' })
})
// After each test — reset overrides
afterEach(() => {
cleanup()
server.resetHandlers()
})
// After all tests — stop the server
afterAll(() => {
server.close()
})
Troubleshooting
Unhandled Request Warnings
If you see
[MSW] Warning: intercepted a request without a matching request handler, it means a test is making an API call that no handler covers. Solutions:- Add a handler in the appropriate handler file
- Override per-test with
server.use()if the endpoint is test-specific - Check the URL — typos in endpoint paths are the most common cause
Handler Ordering
MSW matches handlers in array order (first match wins). If you have a generic handler like
http.get('/api/*'), place it after more specific handlers:typescript
export const handlers = [
http.get('/api/user/:id', specificHandler), // Checked first
http.get('/api/user', listHandler), // Checked second
// Don't put catch-all handlers before specific ones
]
Stale Overrides Between Tests
If tests are failing with unexpected data, check that
server.resetHandlers() is called in afterEach. Without it, server.use() overrides persist across tests. The setup file configures this automatically, but custom test setups may miss it.MSW Not Intercepting in Browser
If the development server is not intercepting requests:
- Verify
NEXT_PUBLIC_MSW_ENABLED=trueinapps/boilerplate/.env.local - Check that
apps/boilerplate/public/mockServiceWorker.jsexists (runcd apps/boilerplate && npx msw init public/to regenerate) - Look for the
MSW (Mock Service Worker) started successfullyconsole message
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/mocks/handlers.ts | Main handler aggregator with basic API handlers |
apps/boilerplate/src/mocks/server.ts | Node.js MSW server for Vitest |
apps/boilerplate/src/mocks/browser.ts | Browser MSW worker for E2E and development |
apps/boilerplate/src/mocks/index.ts | Barrel exports and isMSWEnabled() helper |
apps/boilerplate/src/mocks/ai-handlers.ts | AI chat, RAG, streaming, credit deduction |
apps/boilerplate/src/mocks/lemonsqueezy-handlers.ts | Payment subscriptions, products, tier calculation |
apps/boilerplate/src/mocks/demo-handlers.ts | Demo mode tier-aware responses |
apps/boilerplate/src/test/setup.ts | MSW lifecycle (start, reset, close) |