API Mocking

Mock Service Worker (MSW) setup, handler architecture, and patterns for mocking API responses in tests and development

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:
ModeModuleUsed ByIntercept Method
Serverapps/boilerplate/src/mocks/server.tsVitest (Node.js)Patches http/https modules
Browserapps/boilerplate/src/mocks/browser.tsE2E tests, dev serverService 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:
  1. Environment is development or test
  2. NEXT_PUBLIC_MSW_ENABLED is set to true
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

FileMocked APIsKey Features
handlers.tsUser, posts, auth, dashboard, email, files, health, errorsPagination, file upload validation, delayed responses
ai-handlers.tsAI chat, RAG bot, conversations, usage trackingSSE streaming (OpenAI-compatible), vision-aware credit deduction (text: 1.5, image: 2.0), token estimation
lemonsqueezy-handlers.tsSubscriptions, products, variants, webhooksDynamic tier calculation, grace periods, trial handling
dashboard-handlers.tsDashboard stats, analytics, activity feedRealistic chart data, time-series responses
settings-handlers.tsUser settings, preferencesProfile updates, preference toggles
billing-handlers.tsBilling history, invoices, payment methodsSubscription state management
checkout-handlers.tsDirect checkout, checkout sessionsCheckout URL generation, variant validation
credit-history-handlers.tsCredit transaction historyPaginated credit history, transaction types
credit-preferences-handlers.tsCredit preferencesBonus credit auto-use preference toggle
image-gen-handlers.tsImage generationGPT Image API mock with base64 responses
rate-limit-handlers.tsRate limitingRate limit check and reset mocks
demo-handlers.tsAll demo-mode endpointsTier-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()
  })
})

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)
  })
})

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

VariableDefaultPurpose
NEXT_PUBLIC_MSW_ENABLEDfalseEnable MSW in the browser (set to true in apps/boilerplate/.env.test)
DISABLE_REPOSITORY_MOCKSfalseDisable Repository layer in unit tests (set to true in apps/boilerplate/vitest.config.ts)
NODE_ENVMSW 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:
  1. Add a handler in the appropriate handler file
  2. Override per-test with server.use() if the endpoint is test-specific
  3. 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:
  1. Verify NEXT_PUBLIC_MSW_ENABLED=true in apps/boilerplate/.env.local
  2. Check that apps/boilerplate/public/mockServiceWorker.js exists (run cd apps/boilerplate && npx msw init public/ to regenerate)
  3. Look for the MSW (Mock Service Worker) started successfully console message

Key Files

FilePurpose
apps/boilerplate/src/mocks/handlers.tsMain handler aggregator with basic API handlers
apps/boilerplate/src/mocks/server.tsNode.js MSW server for Vitest
apps/boilerplate/src/mocks/browser.tsBrowser MSW worker for E2E and development
apps/boilerplate/src/mocks/index.tsBarrel exports and isMSWEnabled() helper
apps/boilerplate/src/mocks/ai-handlers.tsAI chat, RAG, streaming, credit deduction
apps/boilerplate/src/mocks/lemonsqueezy-handlers.tsPayment subscriptions, products, tier calculation
apps/boilerplate/src/mocks/demo-handlers.tsDemo mode tier-aware responses
apps/boilerplate/src/test/setup.tsMSW lifecycle (start, reset, close)