Next.js adapter for Scenarist (Pages Router and App Router)
npm install @scenarist/nextjs-adapter

Next.js adapter for Scenarist - test different backend states in your Next.js applications without restarting your dev server or maintaining multiple test environments.
π― Key capability: Test React Server Components with real execution. Your Server Components, Route Handlers, and Server Actions all run exactly as they would in production - only external API calls are mocked.
Problem it solves: Testing error scenarios, loading states, and edge cases in Next.js is painful. You either restart your app repeatedly, maintain complex mock setups per test, or run tests serially to avoid conflicts. Scenarist lets you switch between complete API scenarios instantly via HTTP calls, enabling parallel testing with isolated backend states.
Before Scenarist:
``typescript`
// Every test has fragile per-test mocking
beforeEach(() => {
server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));
});
// Repeat 100 times across test files, hope they don't conflict
With Scenarist:
`typescript
// Define scenario once
const adminScenario = {
id: "admin",
mocks: [
/ complete backend state /
],
};
// Use in any test with one line
await setScenario("test-1", "admin");
// Test runs with complete "admin" backend state, isolated from other tests
`
This package provides complete Next.js integration for Scenarist's scenario management system, supporting both Pages Router and App Router:
- Runtime scenario switching via HTTP endpoints - no restarts needed
- Test isolation using unique test IDs - run tests in parallel
- Automatic MSW integration for request interception - no MSW setup required
- Zero boilerplate - everything wired automatically with one function call
- Both routers supported - Pages Router and App Router with identical functionality
| I want to... | Go to |
| -------------------------------- | ----------------------------------------------------------- |
| See what problems this solves | What is this? |
| Get started in 5 minutes | Quick Start (5 Minutes) |
| Choose between routers | Pages Router vs App Router |
| Set up Pages Router | Pages Router Setup |
| Set up App Router | App Router Setup |
| Switch scenarios in tests | Use in Tests |
| Forward headers to external APIs | Making External API Calls |
| Understand test isolation | Test ID Isolation |
| Reduce test boilerplate | Common Patterns |
| Debug issues | Troubleshooting |
| See full API reference | API Reference |
| Learn about advanced features | Core Functionality Docs |
Choose your router:
- β App Router Getting Started β Server Components, Route Handlers, Server Actions
- β Pages Router Getting Started β API Routes, getServerSideProps, getStaticProps
| Topic | Link |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| π― React Server Components Guide | scenarist.io/frameworks/nextjs-app-router/rsc-guide |
| Why Scenarist? | scenarist.io/getting-started/why-scenarist |
| Tool Comparison | scenarist.io/comparison |
| Parallel Testing | scenarist.io/testing/parallel-testing |
| Writing Scenarios | scenarist.io/scenarios/basic-structure |
| Request Matching | scenarist.io/scenarios/request-matching |
| Response Sequences | scenarist.io/scenarios/response-sequences |
| State-Aware Mocking | scenarist.io/scenarios/state-aware-mocking |
| Logging & Debugging | scenarist.io/reference/logging |
Scenarist provides 20+ powerful features for scenario-based testing. All capabilities work with Next.js (both Pages Router and App Router).
Body matching (partial match) - Match requests based on request body fields
`typescript`
{
method: 'POST',
url: '/api/items',
match: { body: { itemId: 'premium-item' } },
response: { status: 200, body: { price: 100 } }
}
Header matching (exact match) - Perfect for user tier testing
`typescript`
{
method: 'GET',
url: '/api/data',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { limit: 1000 } }
}
Query parameter matching - Different responses for filtered requests
Combined matching - Combine body + headers + query (all must pass)
Specificity-based selection - Most specific mock wins (no need to order carefully)
Fallback mocks - Mocks without match criteria act as catch-all
Single responses - Return same response every time
Response sequences (ordered) - Perfect for polling APIs
`typescript`
{
method: 'GET',
url: '/api/job/:id',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // Stay at final response
}
}
Repeat modes - last (stay at final), cycle (loop), none (exhaust)
Sequence exhaustion with fallback - Exhausted sequences skip to next mock
State capture from requests - Extract values from body/headers/query
State injection via templates - Inject captured state using {{state.X}}
`typescript
// Capture from POST
{
method: 'POST',
url: '/api/cart/items',
captureState: { 'cartItems[]': 'body.item' }, // Append to array
response: { status: 200 }
}
// Inject into GET response
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
count: '{{state.cartItems.length}}'
}
}
}
`
Array append support - Syntax: stateKey[] appends to arrayuser.profile.name
Nested state paths - Support dot notation:
State isolation per test ID - Each test ID has isolated state
State reset on scenario switch - Fresh state for each scenario
Multiple API mocking - Mock any number of external APIs in one scenario
Automatic default fallback - Active scenarios inherit mocks from default, override via specificity
Test ID isolation - Run 100+ tests concurrently without conflicts
Runtime scenario switching - Change backend state with one API call
Path parameters (/users/:id), Wildcard URLs (/api/), Response delays, Custom headers, Strict mode (fail on unmocked requests)
Want to learn more? See Core Functionality Documentation for detailed explanations and examples.
Test how your UI handles API errors without maintaining separate error mocks per test:
`typescript
// Define once
const errorScenario = {
id: "api-error",
name: "API Error",
mocks: [{ method: "GET", url: "/api/", response: { status: 500 } }],
};
// Use in many tests
await setScenario("test-1", "api-error");
// All API calls return 500 for this test
`
Test slow API responses without actual network delays:
`typescript
const slowScenario = {
id: "slow-api",
name: "Slow API",
mocks: [
{
method: "GET",
url: "*/api/data",
response: { status: 200, body: { data: [] }, delay: 3000 }, // 3s delay
},
],
};
// Perfect for testing loading spinners and skeleton screens
`
Test different user permission levels by switching scenarios:
`typescript
const freeUserScenario = {
id: "free",
mocks: [
/ limited features /
],
};
const premiumUserScenario = {
id: "premium",
mocks: [
/ all features /
],
};
// Test switches scenarios mid-suite - no app restart needed
test("free user sees upgrade prompt", async () => {
await setScenario("test-1", "free");
// ... test free tier behavior
});
test("premium user sees all features", async () => {
await setScenario("test-2", "premium");
// ... test premium tier behavior
});
`
Run tests concurrently with different backend states - no conflicts:
`typescript`
// Test 1: Uses 'success' scenario with test-id-1
// Test 2: Uses 'error' scenario with test-id-2
// Test 3: Uses 'slow' scenario with test-id-3
// All running in parallel, completely isolated via test IDs
Want to see these in action? Jump to Quick Start (5 Minutes).
`bashnpm
npm install --save-dev @scenarist/nextjs-adapter msw
Note: All Scenarist types (
ScenaristScenario, ScenaristMock, etc.) are re-exported from @scenarist/nextjs-adapter for convenience. You don't need to install @scenarist/core or @scenarist/msw-adapter separately - they're already included as dependencies.Peer Dependencies:
-
next ^14.0.0 || ^15.0.0
- msw ^2.0.0Quick Start (5 Minutes)
Goal: Switch between success and error scenarios in your tests.
$3
`typescript
// lib/scenarios.ts
import type {
ScenaristScenario,
ScenaristScenarios,
} from "@scenarist/nextjs-adapter";export const defaultScenario: ScenaristScenario = {
id: "default",
name: "Default",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: { status: 200, body: { name: "Default User", role: "user" } },
},
],
};
export const successScenario: ScenaristScenario = {
id: "success",
name: "API Success",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: { status: 200, body: { name: "Alice", role: "user" } },
},
],
};
export const scenarios = {
default: defaultScenario,
success: successScenario,
} as const satisfies ScenaristScenarios;
`$3
`typescript
// lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/pages"; // or /app
import { scenarios } from "./scenarios";export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios, // All scenarios registered upfront
});
// Start MSW in Node.js environment
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
`> CRITICAL: Singleton Pattern Required
>
> You MUST use
export const scenarist = createScenarist(...) as shown above. Do NOT wrap createScenarist() in a function:
>
> `typescript
> // β WRONG - Creates new instance each time
> export function getScenarist() {
> return createScenarist({ enabled: true, scenarios });
> }
>
> // β
CORRECT - Single exported constant
> export const scenarist = createScenarist({ enabled: true, scenarios });
> `
>
> Why: Next.js has a well-documented singleton problem where webpack bundles modules multiple times across different chunks, breaking the classic JavaScript singleton pattern. This is compounded by MSW's process model challenges with Next.jsβNext.js keeps multiple Node.js processes that make global module patches difficult to maintain.
>
> Symptoms of this problem:
>
> - [MSW] Multiple handlers with the same URL pattern warnings
> - Intermittent 500 errors during tests
> - Scenarios not switching properly
> - Different tests getting wrong scenario responses
>
> How Scenarist solves this: The createScenarist() function includes built-in singleton protection using globalThis guards, ensuring only ONE MSW instance exists even when Next.js duplicates your module. This protection only works if you export a constantβwrapping in a function bypasses the singleton guard.$3
Pages Router: Create
pages/api/__scenario__.ts:`typescript
import { scenarist } from "@/lib/scenarist";// In production, scenarist is undefined due to conditional exports
// When the default export is undefined, Next.js treats the route as non-existent
export default scenarist?.createScenarioEndpoint();
`App Router: Create
app/api/%5F%5Fscenario%5F%5F/route.ts:> Why the URL encoding? Next.js App Router treats folders starting with
_ (underscore) as private folders that are excluded from routing. To create a URL route /api/__scenario__, we use %5F (URL-encoded underscore). This creates the actual endpoint at http://localhost:3000/api/__scenario__.`typescript
import { scenarist } from "@/lib/scenarist";// In production, scenarist is undefined due to conditional exports
// When exports are undefined, Next.js treats the route as non-existent
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
`$3
`typescript
import { scenarist } from "@/lib/scenarist";beforeAll(() => scenarist.start()); // Start MSW server
afterAll(() => scenarist.stop()); // Stop MSW server
it("fetches user successfully", async () => {
// Set scenario for this test
await fetch("http://localhost:3000/__scenario__", {
method: "POST",
headers: {
"x-scenarist-test-id": "test-1",
"content-type": "application/json",
},
body: JSON.stringify({ scenario: "success" }),
});
// Make request - MSW intercepts automatically
const response = await fetch("http://localhost:3000/api/user", {
headers: { "x-scenarist-test-id": "test-1" },
});
expect(response.status).toBe(200);
const user = await response.json();
expect(user.name).toBe("Alice");
});
`That's it! You've got runtime scenario switching.
Next steps:
- Add more scenarios for different backend states
- Use test helpers to reduce boilerplate
- Learn about test isolation for parallel tests
- See advanced features like request matching and sequences
---
Pages Router vs App Router
| Aspect | Pages Router | App Router |
| ---------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Import Path |
@scenarist/nextjs-adapter/pages | @scenarist/nextjs-adapter/app |
| Setup File | pages/api/__scenario__.ts | app/api/%5F%5Fscenario%5F%5F/route.ts \* |
| Scenario Endpoint | export default scenarist.createScenarioEndpoint() | const handler = scenarist.createScenarioEndpoint();
export const POST = handler;
export const GET = handler; |
| Core Functionality | β
Same scenarios, same behavior | β
Same scenarios, same behavior |\* App Router uses
%5F%5Fscenario%5F%5F (URL-encoded) because folders starting with _ are treated as private folders in Next.js App Router. See App Router Setup for details.Key Insight: Choose based on your Next.js version - all Scenarist features work identically in both routers.
Detailed setup guides:
- Pages Router Setup - Full walkthrough for Pages Router
- App Router Setup - Full walkthrough for App Router
---
Pages Router Setup
$3
`typescript
// lib/scenarios/default.ts
import type { ScenaristScenario } from "@scenarist/nextjs-adapter/pages";export const defaultScenario: ScenaristScenario = {
id: "default",
name: "Default Scenario",
description: "Baseline responses for all APIs",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: {
status: 200,
body: {
id: "000",
name: "Default User",
role: "user",
},
},
},
],
};
// lib/scenarios/admin-user.ts
export const adminUserScenario: ScenaristScenario = {
id: "admin-user",
name: "Admin User",
description: "User with admin privileges",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: {
status: 200,
body: {
id: "123",
name: "Admin User",
role: "admin",
},
},
},
],
};
`$3
`typescript
// lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/pages";
import { scenarios } from "./scenarios";export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios, // All scenarios registered upfront
strictMode: false, // Allow unmocked requests to pass through to real APIs
});
// Start MSW in Node.js environment
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
`$3
`typescript
// pages/api/__scenario__.ts
import { scenarist } from "../../lib/scenarist";// In production, scenarist is undefined due to conditional exports
// When the default export is undefined, Next.js treats the route as non-existent
export default scenarist?.createScenarioEndpoint();
`This single line creates a Next.js API route that handles both GET and POST requests for scenario management.
$3
`typescript
// tests/api.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { scenarist } from "../lib/scenarist";describe("User API", () => {
beforeAll(() => scenarist.start());
afterAll(() => scenarist.stop());
it("should return admin user", async () => {
// Set scenario for this test
await fetch("http://localhost:3000/__scenario__", {
method: "POST",
headers: {
"x-scenarist-test-id": "admin-test",
"content-type": "application/json",
},
body: JSON.stringify({ scenario: "admin-user" }),
});
// Make request - MSW intercepts automatically
const response = await fetch("http://localhost:3000/api/user", {
headers: { "x-scenarist-test-id": "admin-test" },
});
const user = await response.json();
expect(user.role).toBe("admin");
});
});
`---
App Router Setup
$3
Same as Pages Router - see Define Scenarios above.
$3
`typescript
// lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/app";
import { scenarios } from "./scenarios";export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios, // All scenarios registered upfront
strictMode: false, // Allow unmocked requests to pass through to real APIs
});
// Start MSW in Node.js environment
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
`$3
`typescript
// app/api/%5F%5Fscenario%5F%5F/route.ts
import { scenarist } from "@/lib/scenarist";// In production, scenarist is undefined due to conditional exports
// When exports are undefined, Next.js treats the route as non-existent
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
`The App Router uses Web standard Request/Response API and requires explicit exports for each HTTP method.
$3
Same as Pages Router - see Use in Tests above. The scenario endpoint works identically.
---
API Reference
$3
Creates a Scenarist instance with everything wired automatically.
Import:
`typescript
// Pages Router
import { createScenarist } from "@scenarist/nextjs-adapter/pages";// App Router
import { createScenarist } from "@scenarist/nextjs-adapter/app";
`Parameters:
`typescript
type AdapterOptions = {
enabled: boolean; // Whether mocking is enabled
scenarios: T; // REQUIRED - scenarios object (all scenarios registered upfront)
strictMode?: boolean; // Return 501 for unmocked requests (default: false)
headers?: {
testId?: string; // Header for test ID (default: 'x-scenarist-test-id')
};
defaultTestId?: string; // Default test ID (default: 'default-test')
registry?: ScenarioRegistry; // Custom registry (default: InMemoryScenarioRegistry)
store?: ScenarioStore; // Custom store (default: InMemoryScenarioStore)
stateManager?: StateManager; // Custom state manager (default: InMemoryStateManager)
sequenceTracker?: SequenceTracker; // Custom sequence tracker (default: InMemorySequenceTracker)
};
`Returns:
`typescript
type Scenarist = {
config: ScenaristConfig; // Resolved configuration (headers, etc.)
createScenarioEndpoint: () => Handler; // Creates scenario endpoint handler
switchScenario: (
testId: string,
scenarioId: ScenarioIds,
variant?: string,
) => ScenaristResult;
getActiveScenario: (testId: string) => ActiveScenario | undefined;
getScenarioById: (
scenarioId: ScenarioIds,
) => ScenaristScenario | undefined;
listScenarios: () => ReadonlyArray;
clearScenario: (testId: string) => void;
start: () => void; // Start MSW server
stop: () => Promise; // Stop MSW server
};
`Key Difference from Express Adapter:
Unlike Express, Next.js doesn't have global middleware. Instead, you manually create the scenario endpoint using
createScenarioEndpoint():`typescript
// Pages Router - single default export
export default scenarist.createScenarioEndpoint();// App Router - explicit method exports
const handler = scenarist.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
`$3
The endpoint handler exposes these operations:
####
POST /__scenario__ - Set Active ScenarioRequest:
`typescript
{
scenario: string; // Scenario ID (required)
variant?: string; // Variant name (optional)
}
`Response (200):
`typescript
{
success: true;
testId: string;
scenarioId: string;
variant?: string;
}
`Example:
`typescript
await fetch("http://localhost:3000/__scenario__", {
method: "POST",
headers: {
"x-scenarist-test-id": "test-123",
"content-type": "application/json",
},
body: JSON.stringify({ scenario: "user-logged-in" }),
});
`####
GET /__scenario__ - Get Active ScenarioResponse (200):
`typescript
{
testId: string;
scenarioId: string;
scenarioName?: string;
}
`Response (404) - No Active Scenario:
`typescript
{
error: "No active scenario for this test ID";
testId: string;
}
`Example:
`typescript
const response = await fetch("http://localhost:3000/__scenario__", {
headers: { "x-scenarist-test-id": "test-123" },
});const data = await response.json();
console.log(data.scenarioId); // 'user-logged-in'
`####
GET /__scenarist__/state - Debug State EndpointInspect the current test state for debugging. Useful when testing multi-stage flows with
afterResponse.setState.Response (200):
`typescript
{
testId: string;
state: Record; // Current test state
}
`Example:
`typescript
const response = await fetch("http://localhost:3000/__scenarist__/state", {
headers: { "x-scenarist-test-id": "test-123" },
});const data = await response.json();
console.log(data.state); // { submitted: true, phase: "review" }
`Creating the debug endpoint:
Pages Router:
`typescript
// pages/api/__scenarist__/state.ts
import { scenarist } from "@/lib/scenarist";export default scenarist?.createStateEndpoint();
`App Router:
`typescript
// app/api/%5F%5Fscenarist%5F%5F/state/route.ts
import { scenarist } from "@/lib/scenarist";const handler = scenarist?.createStateEndpoint();
export const GET = handler;
`When to use:
- Debugging failing tests with state-aware mocking
- Verifying
afterResponse.setState mutations
- Testing conditional afterResponse behavior (see ADR-0020)Core Concepts
Scenarist's core functionality is framework-agnostic. For deep understanding of these concepts (request matching, sequences, stateful mocks), see Core Functionality Documentation.
Quick reference for Next.js:
$3
Each request includes an
x-scenarist-test-id header for parallel test isolation:`typescript
// Test 1
headers: { 'x-scenarist-test-id': 'test-1' } // Uses scenario A// Test 2 (parallel!)
headers: { 'x-scenarist-test-id': 'test-2' } // Uses scenario B
`Each test ID has completely isolated:
- Active scenario selection
- Sequence positions (for polling scenarios)
- Captured state (for stateful mocks)
Learn more: Test Isolation in Core Docs
$3
IMPORTANT: When your API routes call external APIs (or services mocked by Scenarist), you must forward Scenarist headers so MSW can intercept with the correct scenario.
Why Next.js needs this: Unlike Express (which uses AsyncLocalStorage middleware), Next.js API routes have no middleware layer to automatically propagate test IDs. You must manually forward the headers.
Use the safe helper functions provided by the adapter:
Pages Router:
`typescript
// pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Fetch from external API with Scenarist headers forwarded
const response = await fetch("http://external-api.com/products", {
headers: {
...getScenaristHeaders(req), // β
Scenarist infrastructure headers
"content-type": "application/json", // β
Your application headers
"x-user-tier": req.headers["x-user-tier"], // β
Other app-specific headers
},
});
const data = await response.json();
res.json(data);
}
`App Router Route Handlers:
`typescript
// app/api/products/route.ts
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";export async function GET(request: Request) {
const response = await fetch("http://external-api.com/products", {
headers: {
...getScenaristHeaders(request),
"content-type": "application/json",
},
});
const data = await response.json();
return Response.json(data);
}
`App Router Server Components:
`typescript
// app/products/page.tsx
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';export default async function ProductsPage() {
// Server Components use headers() which returns ReadonlyHeaders, not Request
const headersList = await headers();
const response = await fetch('http://external-api.com/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // β
For ReadonlyHeaders
'content-type': 'application/json',
},
});
const data = await response.json();
return ;
}
`What these helpers do:
- Extract test ID from request headers (
x-scenarist-test-id by default)
- Respect your configured testIdHeaderName and defaultTestId
- Return object with Scenarist headers ready to spread
- Safe to use in production (return empty object when scenarist is undefined)When to use which helper:
-
getScenaristHeaders(request | req) - Route Handlers (Request object) or Pages Router API routes (NextApiRequest)
- getScenaristHeadersFromReadonlyHeaders(headersList) - App Router Server Components (ReadonlyHeaders from headers())Key Distinction:
- Scenarist headers (
x-scenarist-test-id) - Infrastructure for test isolation
- Application headers (x-user-tier, content-type) - Your app's business logicOnly Scenarist headers need forwarding via helper functions. Your application headers are independent.
For architectural rationale, see: ADR-0007: Framework-Specific Header Forwarding
$3
createScenarist() automatically wires MSW for request interception. You never see MSW code directly.What it does:
1. Creates MSW server with dynamic handler
2. Extracts test ID from request headers
3. Looks up active scenario for that test ID
4. Returns mocked responses based on scenario
Next.js-specific details:
- Pages Router: Uses Next.js API routes
- App Router: Uses Web standard Request/Response
- Both: Full test ID isolation via headers
$3
Active scenario mocks take precedence; unmocked endpoints fall back to default scenario:
`typescript
// Default covers all endpoints
const defaultScenario = {
id: "default",
mocks: [
{ method: "GET", url: "*/api/users", response: { status: 200, body: [] } },
{ method: "GET", url: "*/api/orders", response: { status: 200, body: [] } },
],
};// Test scenario overrides only specific endpoints
const errorScenario = {
id: "error",
mocks: [
{ method: "GET", url: "*/api/users", response: { status: 500 } },
// Orders uses default scenario
],
};
`Learn more: Fallback Behavior in Core Docs
Type-Safe Scenario IDs
TypeScript automatically infers scenario names from your scenarios object, providing autocomplete and compile-time safety.
$3
`typescript
// lib/scenarios.ts
import type {
ScenaristScenario,
ScenaristScenarios,
} from "@scenarist/nextjs-adapter/pages";export const scenarios = {
default: { id: "default", name: "Default", mocks: [] },
success: { id: "success", name: "Success", mocks: [] },
error: { id: "error", name: "Error", mocks: [] },
timeout: { id: "timeout", name: "Timeout", mocks: [] },
} as const satisfies ScenaristScenarios;
// lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/pages";
import { scenarios } from "./scenarios";
export const scenarist = createScenarist({
enabled: true,
scenarios, // β
Autocomplete + type-checked!
});
`$3
`typescript
// β
Valid - TypeScript knows about these scenario IDs
scenarist.switchScenario("test-123", "success");
scenarist.switchScenario("test-123", "error");
scenarist.switchScenario("test-123", "timeout");// β TypeScript error - 'invalid-name' is not a valid scenario ID
scenarist.switchScenario("test-123", "invalid-name");
// ^^^^^^^^^^^^^^
// Argument of type '"invalid-name"' is not assignable to parameter of type
// '"default" | "success" | "error" | "timeout"'
`$3
`typescript
// tests.ts
import { scenarios } from "./scenarios";// β
Type-safe scenario switching
await fetch("http://localhost:3000/__scenario__", {
method: "POST",
headers: {
"x-scenarist-test-id": "test-1",
"content-type": "application/json",
},
body: JSON.stringify({ scenario: "success" }), // β
Autocomplete works!
});
// Or reference by object key for refactor-safety
await fetch("http://localhost:3000/__scenario__", {
method: "POST",
headers: {
"x-scenarist-test-id": "test-1",
"content-type": "application/json",
},
body: JSON.stringify({ scenario: scenarios.success.id }), // β
Even safer!
});
`What you get:
- β
IDE autocomplete for all scenario names
- β
Compile-time errors for typos (catch bugs before runtime)
- β
Refactor-safe (rename scenarios and all usages update)
- β
Self-documenting code (see all available scenarios in one place)
- β
Single source of truth (scenarios object defines everything)
Common Patterns
$3
Create helper functions to reduce boilerplate:
`typescript
// tests/helpers.ts
const API_BASE = "http://localhost:3000";export const setScenario = async (
testId: string,
scenario: string,
variant?: string,
) => {
await fetch(
${API_BASE}/__scenario__, {
method: "POST",
headers: {
"x-scenarist-test-id": testId,
"content-type": "application/json",
},
body: JSON.stringify({ scenario, variant }),
});
};export const makeRequest = (
testId: string,
path: string,
options?: RequestInit,
) => {
return fetch(
${API_BASE}${path}, {
...options,
headers: {
...options?.headers,
"x-scenarist-test-id": testId,
},
});
};
`Usage:
`typescript
import { setScenario, makeRequest } from "./helpers";test("payment flow", async () => {
const testId = "payment-test";
await setScenario(testId, "payment-success");
const response = await makeRequest(testId, "/api/charge", { method: "POST" });
expect(response.status).toBe(200);
});
`$3
Generate unique test IDs automatically using a factory function:
`typescript
import { randomUUID } from "crypto";describe("API Tests", () => {
// Factory function - no shared mutable state
const createTestId = () => randomUUID();
it("should process payment", async () => {
const testId = createTestId(); // Fresh ID per test
await setScenario(testId, "payment-success");
const response = await makeRequest(testId, "/api/charge", {
method: "POST",
});
expect(response.status).toBe(200);
});
it("should handle payment decline", async () => {
const testId = createTestId(); // Independent state
await setScenario(testId, "payment-declined");
const response = await makeRequest(testId, "/api/charge", {
method: "POST",
});
expect(response.status).toBe(402);
});
});
`$3
β οΈ Security Warning: Only enable scenario endpoints in development/test environments, NEVER in production.
Why? The
/__scenario__ endpoint allows arbitrary mock switching, which could be exploited in production to bypass security, fake data, or cause unexpected behavior.Safe configuration:
`typescript
// lib/scenarist.ts
// β
CORRECT - Only enabled in safe environments
const scenarist = createScenarist({
enabled:
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
scenarios,
strictMode: false,
});// β WRONG - Dangerous in production
const scenarist = createScenarist({
enabled: true, // Always on, including production!
scenarios,
});
`Production checklist:
- β
enabled is conditional (never hardcoded true)
- β
Environment checks use process.env.NODE_ENV
- β
/__scenario__ endpoints not exposed in production buildsDuring development, manually switch scenarios with curl:
`bash
Switch to error scenario
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-d '{"scenario": "payment-declined"}'Check active scenario
curl http://localhost:3000/__scenario__
`Configuration
$3
`typescript
// Test-only
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
strictMode: true, // Fail if any unmocked request
});// Development and test
const scenarist = createScenarist({
enabled:
process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development",
scenarios,
strictMode: false, // Allow passthrough to real APIs
});
// Opt-in with environment variable
const scenarist = createScenarist({
enabled: process.env.ENABLE_MOCKING === "true",
scenarios,
strictMode: false,
});
`$3
Controls behavior when no mock matches a request.
strictMode: false (recommended for development):- Unmocked requests pass through to real APIs
- Useful when only mocking specific endpoints
- Default scenario provides fallback for common endpoints
- Best for development where you want partial mocking
strictMode: true (recommended for tests):- Unmocked requests return 501 Not Implemented
- Ensures tests don't accidentally hit real APIs
- Catches missing mocks early in test development
- Best for test isolation and reproducibility
Example:
`typescript
// Development: Only mock failing endpoints, let others pass through
const minimalScenarios = {
default: minimalScenario, // Only critical mocks
} as const satisfies ScenaristScenarios;const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "development",
scenarios: minimalScenarios,
strictMode: false, // Let unmocked APIs pass through to real services
});
// Testing: Ensure complete isolation
const testScenarios = {
default: completeScenario, // Mock all endpoints
} as const satisfies ScenaristScenarios;
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios: testScenarios,
strictMode: true, // Fail loudly if any endpoint isn't mocked
});
`When strictMode is true:
- Request for mocked endpoint β Returns defined mock response β
- Request for unmocked endpoint β Returns 501 Not Implemented β
- Easy to spot missing mocks during test development
When strictMode is false:
- Request for mocked endpoint β Returns defined mock response β
- Request for unmocked endpoint β Passes through to real API π
- Useful for incremental mocking in development
$3
`typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
headers: {
testId: "x-my-test-id",
},
});
`Logging & Debugging
Scenarist includes a flexible logging system for debugging scenario matching, state management, and request handling. Logging is disabled by default and must be explicitly enabled. For comprehensive documentation including log categories, custom loggers, and Vitest configuration, see the full logging guide.
$3
`typescript
// App Router
import {
createScenarist,
createConsoleLogger,
} from "@scenarist/nextjs-adapter/app";// Pages Router
import {
createScenarist,
createConsoleLogger,
} from "@scenarist/nextjs-adapter/pages";
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
// Enable logging with pretty format
logger: createConsoleLogger({ level: "info", format: "pretty" }),
});
`$3
For easy toggling without code changes:
`typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from "@scenarist/nextjs-adapter/app";// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray> = [
"error",
"warn",
"info",
"debug",
"trace",
];
const LOG_FORMATS: ReadonlyArray = ["pretty", "json"];
const parseLogLevel = (
value: string | undefined,
): Exclude =>
LOG_LEVELS.includes(value as Exclude)
? (value as Exclude)
: "info";
const parseLogFormat = (value: string | undefined): LogFormat =>
LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : "pretty";
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
// Enable via SCENARIST_LOG=1 environment variable
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});
`Then run tests with logging:
`bash
Enable info-level logging
SCENARIST_LOG=1 pnpm testEnable debug-level logging for match troubleshooting
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
`> Note:
SCENARIST_LOG is a convention for your code, not something Scenarist reads automatically. You must explicitly pass a logger to createScenarist() as shown above.$3
| Level | Description | Use Case |
| ------- | ----------------- | --------------------------------------------- |
|
error | Critical failures | Scenario not found, invalid config |
| warn | Potential issues | No mock matched, sequence exhausted |
| info | Key events | Scenario switched, mock selected |
| debug | Decision logic | Match criteria evaluation, specificity scores |
| trace | Verbose details | Request/response bodies, template replacement |$3
Pretty format (default) - human-readable with emojis and colors:
`
12:34:56.789 INF π¬ [test-user-login] scenario | scenario_switched scenarioId=premium-user
12:34:56.801 INF π― [test-user-login] matching | mock_selected mockIndex=2 specificity=5
12:34:56.810 INF πΎ [test-user-login] state | state_captured key=userId value=user-123
12:34:56.815 WRN π― [test-user-login] matching | mock_no_match url=/api/unknown
`JSON format - for log aggregation tools (Datadog, Splunk, etc.):
`json
{"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","timestamp":1732650896789}
{"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","data":{"mockIndex":2,"specificity":5},"timestamp":1732650896801}
`For more details including log categories, custom loggers, and Vitest configuration, see the full logging documentation.
Troubleshooting
$3
Problem: Scenario endpoints work but external API calls go to real endpoints.
Solution: Ensure you've called
scenarist.start() before tests and scenarist.stop() after:`typescript
beforeAll(() => scenarist.start()); // Starts MSW server
afterAll(() => scenarist.stop()); // Stops MSW server
`$3
Problem: Different tests are seeing each other's active scenarios.
Solution: Ensure you're sending the
x-scenarist-test-id header with every request:`typescript
// β Wrong - missing header on second request
await setScenario("test-1", "my-scenario");
const response = await fetch("http://localhost:3000/api/data"); // No test ID!// β
Correct - header on all requests
await setScenario("test-1", "my-scenario");
const response = await fetch("http://localhost:3000/api/data", {
headers: { "x-scenarist-test-id": "test-1" },
});
`$3
Problem:
Scenario not found when setting scenario.Solution: Ensure the scenario is included in your scenarios object:
`typescript
// lib/scenarios.ts
export const scenarios = {
default: defaultScenario,
"my-scenario": myScenario, // β
Include in scenarios object
} as const satisfies ScenaristScenarios;// lib/scenarist.ts
const scenarist = createScenarist({
enabled: true,
scenarios, // All scenarios registered automatically
});
// tests
await setScenario("test-1", "my-scenario"); // β
Now works
`$3
Problem: Type errors with Next.js request/response types.
Solution: Ensure your
next peer dependency version matches the adapter's supported versions (^14.0.0 || ^15.0.0).TypeScript
This package is written in TypeScript and includes full type definitions.
Exported Types:
`typescript
// Pages Router - adapter-specific types
import type {
PagesAdapterOptions,
PagesScenarist,
PagesRequestContext,
} from "@scenarist/nextjs-adapter/pages";// Pages Router - core types (re-exported for convenience)
import type {
ScenaristScenario,
ScenaristMock,
ScenaristResponse,
ScenaristSequence,
ScenaristMatch,
ScenaristCaptureConfig,
ScenaristScenarios,
ScenaristConfig,
ScenaristResult,
} from "@scenarist/nextjs-adapter/pages";
// App Router - adapter-specific types
import type {
AppAdapterOptions,
AppScenarist,
AppRequestContext,
} from "@scenarist/nextjs-adapter/app";
// App Router - core types (re-exported for convenience)
import type {
ScenaristScenario,
ScenaristMock,
ScenaristResponse,
ScenaristSequence,
ScenaristMatch,
ScenaristCaptureConfig,
ScenaristScenarios,
ScenaristConfig,
ScenaristResult,
} from "@scenarist/nextjs-adapter/app";
`Note: All core types are re-exported from both
/pages and /app subpaths for convenience. You only need one import path for all Scenarist types - just import from the subpath that matches your Next.js router.Advanced Usage
Note: Most users should use
createScenarist(), which handles everything automatically.
For framework authors or custom integrations only - click to expand
If you need custom wiring for specialized use cases, you can access low-level components:
`typescript
// Pages Router low-level components
import {
PagesRequestContext,
createScenarioEndpoint,
} from "@scenarist/nextjs-adapter/pages";// App Router low-level components
import {
AppRequestContext,
createScenarioEndpoint,
} from "@scenarist/nextjs-adapter/app";
`Use cases for low-level APIs:
- Building a custom adapter for a Next.js-like framework
- Integrating with non-standard request handling
- Creating custom middleware chains
- Implementing custom test ID extraction logic
For typical Next.js applications, use
createScenarist() instead. It provides the same functionality with zero configuration.Production Tree-Shaking
Problem: MSW and Scenarist are test-only dependencies that should never appear in production bundles.
Solution: The Next.js adapter uses conditional exports to eliminate all testing code from production builds automatically.
$3
When
NODE_ENV=production, bundlers (Next.js webpack/Turbopack, esbuild, Vite, etc.) automatically resolve to production entry points that return undefined with zero imports:`typescript
// Production build imports this instead:
// packages/nextjs-adapter/src/app/production.ts (or pages/production.ts)
export const createScenarist = () => undefined;
// No imports = 100% tree-shaking
`$3
Before (development):
- Scenarist adapter + Core + MSW: ~320kb
After (production with tree-shaking):
- 0kb - Complete elimination
$3
Both Next.js example apps include verification scripts:
`bash
App Router example
cd apps/nextjs-app-router-example
pnpm verify:treeshakingPages Router example
cd apps/nextjs-pages-router-example
pnpm verify:treeshaking
`This builds your app with
NODE_ENV=production and verifies zero MSW code exists in the .next/ bundle.$3
Next.js (Automatic):
- Next.js webpack/Turbopack automatically respects
NODE_ENV=production
- No configuration needed - tree-shaking works out of the boxCustom Bundlers:
If using a custom bundler, ensure it resolves the
"production" condition:`javascript
// esbuild
esbuild.build({
conditions: ["production"], // Resolves production entry points
define: { "process.env.NODE_ENV": '"production"' },
});// Webpack
module.exports = {
resolve: {
conditionNames: ["production", "import"],
},
};
// Vite
export default {
resolve: {
conditions: ["production"],
},
};
`$3
When tree-shaking succeeds, your production bundle has:
- β No MSW runtime code (
setupWorker, http.get, HttpResponse.json)
- β No Scenarist core code (scenario manager, state manager, etc.)
- β No Zod validation schemas
- β No adapter code (request context, endpoints, etc.)
- β
Only your application code$3
If
verify:treeshaking fails:1. Check
NODE_ENV: Ensure NODE_ENV=production during build
`bash
NODE_ENV=production next build
`2. Check bundler configuration: Verify
"production" condition is resolved
- Next.js: Should work automatically
- Custom bundlers: Add conditions: ['production']3. Check dynamic imports: Avoid dynamic imports that bypass tree-shaking
`typescript
// β BAD - Bypasses tree-shaking
const scenarist = await import('@scenarist/nextjs-adapter/app'); // β
GOOD - Enables tree-shaking
import { createScenarist } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({ ... });
`4. Inspect bundle: Check
.next/ directory for MSW strings
`bash
grep -r "setupWorker\|HttpResponse\.json" .next/
`$3
The adapter uses package.json conditional exports to provide different entry points per environment:
`json
{
"exports": {
"./app": {
"types": "./dist/app/index.d.ts",
"production": "./dist/app/production.js", // Returns undefined
"import": "./dist/app/index.js" // Full implementation
},
"./pages": {
"types": "./dist/pages/index.d.ts",
"production": "./dist/pages/production.js", // Returns undefined
"import": "./dist/pages/index.js" // Full implementation
}
}
}
`Key Insight: Since the production entry point has zero imports, bundlers eliminate the entire dependency chain (adapter β core β MSW β Zod). This is more effective than dynamic imports or
NODE_ENV checks, which can't guarantee complete elimination.For architectural rationale, see: Tree-Shaking Investigation
Documentation
π Full Documentation - Complete guides, API reference, and examples.
Contributing
See CONTRIBUTING.md for development setup and guidelines.
License
MIT
Related Packages
- @scenarist/core - Core scenario management
- @scenarist/express-adapter - Express.js adapter
- @scenarist/msw-adapter - MSW integration (used internally)
Note: The MSW adapter is used internally by this package. Users of
@scenarist/nextjs-adapter` don't need to interact with it directly.