@scenarist/nextjs-adapter
v0.4.10
Published
Next.js adapter for Scenarist (Pages Router and App Router)
Maintainers
Readme
@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.
What is this?
Before Scenarist:
// 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 conflictWith Scenarist:
// 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 testsThis 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
Quick Navigation
| 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 |
📖 Documentation
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 |
Core Capabilities
Scenarist provides 20+ powerful features for scenario-based testing. All capabilities work with Next.js (both Pages Router and App Router).
Request Matching (6 capabilities)
Body matching (partial match) - Match requests based on request body fields
{
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
{
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
Response Sequences (4 capabilities)
Single responses - Return same response every time Response sequences (ordered) - Perfect for polling APIs
{
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
Stateful Mocks (6 capabilities)
State capture from requests - Extract values from body/headers/query
State injection via templates - Inject captured state using {{state.X}}
// 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 array
Nested state paths - Support dot notation: user.profile.name
State isolation per test ID - Each test ID has isolated state
State reset on scenario switch - Fresh state for each scenario
Core Features (4 capabilities)
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
Additional Features
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.
Common Use Cases
Testing Error Scenarios
Test how your UI handles API errors without maintaining separate error mocks per test:
// 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 testTesting Loading States
Test slow API responses without actual network delays:
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 screensTesting User Tiers
Test different user permission levels by switching scenarios:
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
});Parallel Test Execution
Run tests concurrently with different backend states - no conflicts:
// 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 IDsWant to see these in action? Jump to Quick Start (5 Minutes).
Installation
# npm
npm install --save-dev @scenarist/nextjs-adapter msw
# pnpm
pnpm add -D @scenarist/nextjs-adapter msw
# yarn
yarn add -D @scenarist/nextjs-adapter mswNote: 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.0msw^2.0.0
Quick Start (5 Minutes)
Goal: Switch between success and error scenarios in your tests.
1. Define Scenarios
// 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;2. Create Scenarist Instance
// 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 wrapcreateScenarist()in a function:// ❌ 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 patternwarnings- 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 usingglobalThisguards, 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. Create Scenario Endpoint
Pages Router: Create 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();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 athttp://localhost:3000/api/__scenario__.
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;4. Use in Tests
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
1. Define Scenarios
// 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",
},
},
},
],
};2. Create Scenarist Instance
// 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. Create Scenario Endpoint
// 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.
4. Use in Tests
// 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
1. Define Scenarios
Same as Pages Router - see Define Scenarios above.
2. Create Scenarist Instance
// 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. Create Scenario Route Handlers
// 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.
4. Use in Tests
Same as Pages Router - see Use in Tests above. The scenario endpoint works identically.
API Reference
createScenarist(options)
Creates a Scenarist instance with everything wired automatically.
Import:
// Pages Router
import { createScenarist } from "@scenarist/nextjs-adapter/pages";
// App Router
import { createScenarist } from "@scenarist/nextjs-adapter/app";Parameters:
type AdapterOptions<T extends ScenaristScenarios> = {
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:
type Scenarist<T extends ScenaristScenarios> = {
config: ScenaristConfig; // Resolved configuration (headers, etc.)
createScenarioEndpoint: () => Handler; // Creates scenario endpoint handler
switchScenario: (
testId: string,
scenarioId: ScenarioIds<T>,
variant?: string,
) => ScenaristResult<void, Error>;
getActiveScenario: (testId: string) => ActiveScenario | undefined;
getScenarioById: (
scenarioId: ScenarioIds<T>,
) => ScenaristScenario | undefined;
listScenarios: () => ReadonlyArray<ScenaristScenario>;
clearScenario: (testId: string) => void;
start: () => void; // Start MSW server
stop: () => Promise<void>; // 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():
// 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;Scenario Endpoints
The endpoint handler exposes these operations:
POST /__scenario__ - Set Active Scenario
Request:
{
scenario: string; // Scenario ID (required)
variant?: string; // Variant name (optional)
}Response (200):
{
success: true;
testId: string;
scenarioId: string;
variant?: string;
}Example:
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 Scenario
Response (200):
{
testId: string;
scenarioId: string;
scenarioName?: string;
}Response (404) - No Active Scenario:
{
error: "No active scenario for this test ID";
testId: string;
}Example:
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 Endpoint
Inspect the current test state for debugging. Useful when testing multi-stage flows with afterResponse.setState.
Response (200):
{
testId: string;
state: Record<string, unknown>; // Current test state
}Example:
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:
// pages/api/__scenarist__/state.ts
import { scenarist } from "@/lib/scenarist";
export default scenarist?.createStateEndpoint();App Router:
// 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.setStatemutations - Testing conditional
afterResponsebehavior (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:
Test ID Isolation
Each request includes an x-scenarist-test-id header for parallel test isolation:
// Test 1
headers: { 'x-scenarist-test-id': 'test-1' } // Uses scenario A
// Test 2 (parallel!)
headers: { 'x-scenarist-test-id': 'test-2' } // Uses scenario BEach 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
Making External API Calls
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:
// 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:
// 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:
// 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 <ProductList products={data.products} />;
}What these helpers do:
- Extract test ID from request headers (
x-scenarist-test-idby default) - Respect your configured
testIdHeaderNameanddefaultTestId - 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 fromheaders())
Key Distinction:
- Scenarist headers (
x-scenarist-test-id) - Infrastructure for test isolation - Application headers (
x-user-tier,content-type) - Your app's business logic
Only Scenarist headers need forwarding via helper functions. Your application headers are independent.
For architectural rationale, see: ADR-0007: Framework-Specific Header Forwarding
Automatic MSW Integration
createScenarist() automatically wires MSW for request interception. You never see MSW code directly.
What it does:
- Creates MSW server with dynamic handler
- Extracts test ID from request headers
- Looks up active scenario for that test ID
- 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
Default Scenario Fallback
Active scenario mocks take precedence; unmocked endpoints fall back to default scenario:
// 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.
How It Works
// 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!
});Type Safety Benefits
// ✅ 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"'In Tests
// 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
Pattern 1: Test Helpers
Create helper functions to reduce boilerplate:
// 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:
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);
});Pattern 2: Unique Test IDs
Generate unique test IDs automatically using a factory function:
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);
});
});Pattern 3: Development Workflows
⚠️ 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:
// 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:
- ✅
enabledis conditional (never hardcodedtrue) - ✅ Environment checks use
process.env.NODE_ENV - ✅
/__scenario__endpoints not exposed in production builds
During development, manually switch scenarios with curl:
# 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
Environment-Specific
// 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,
});strictMode Option
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:
// 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
Custom Headers
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.
Quick Start
// 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" }),
});Environment Variable Pattern
For easy toggling without code changes:
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from "@scenarist/nextjs-adapter/app";
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray<Exclude<LogLevel, "silent">> = [
"error",
"warn",
"info",
"debug",
"trace",
];
const LOG_FORMATS: ReadonlyArray<LogFormat> = ["pretty", "json"];
const parseLogLevel = (
value: string | undefined,
): Exclude<LogLevel, "silent"> =>
LOG_LEVELS.includes(value as Exclude<LogLevel, "silent">)
? (value as Exclude<LogLevel, "silent">)
: "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:
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging for match troubleshooting
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm testNote:
SCENARIST_LOGis a convention for your code, not something Scenarist reads automatically. You must explicitly pass aloggertocreateScenarist()as shown above.
Log Levels
| 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 |
Sample Output
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/unknownJSON format - for log aggregation tools (Datadog, Splunk, etc.):
{"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
Scenarios switch but requests aren't mocked
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:
beforeAll(() => scenarist.start()); // Starts MSW server
afterAll(() => scenarist.stop()); // Stops MSW serverTests see each other's scenarios
Problem: Different tests are seeing each other's active scenarios.
Solution: Ensure you're sending the x-scenarist-test-id header with every request:
// ❌ 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" },
});Scenario not found error
Problem: Scenario not found when setting scenario.
Solution: Ensure the scenario is included in your scenarios object:
// 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 worksTypeScript errors with Next.js types
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:
// 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.
If you need custom wiring for specialized use cases, you can access low-level components:
// 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.
How It Works
When NODE_ENV=production, bundlers (Next.js webpack/Turbopack, esbuild, Vite, etc.) automatically resolve to production entry points that return undefined with zero imports:
// Production build imports this instead:
// packages/nextjs-adapter/src/app/production.ts (or pages/production.ts)
export const createScenarist = () => undefined;
// No imports = 100% tree-shakingBundle Size Impact
Before (development):
- Scenarist adapter + Core + MSW: ~320kb
After (production with tree-shaking):
- 0kb - Complete elimination
Verifying Tree-Shaking
Both Next.js example apps include verification scripts:
# App Router example
cd apps/nextjs-app-router-example
pnpm verify:treeshaking
# Pages Router example
cd apps/nextjs-pages-router-example
pnpm verify:treeshakingThis builds your app with NODE_ENV=production and verifies zero MSW code exists in the .next/ bundle.
Bundler Configuration
Next.js (Automatic):
- Next.js webpack/Turbopack automatically respects
NODE_ENV=production - No configuration needed - tree-shaking works out of the box
Custom Bundlers:
If using a custom bundler, ensure it resolves the "production" condition:
// 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"],
},
};What Gets Eliminated
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
Troubleshooting Tree-Shaking
If verify:treeshaking fails:
Check
NODE_ENV: EnsureNODE_ENV=productionduring buildNODE_ENV=production next buildCheck bundler configuration: Verify
"production"condition is resolved- Next.js: Should work automatically
- Custom bundlers: Add
conditions: ['production']
Check dynamic imports: Avoid dynamic imports that bypass tree-shaking
// ❌ 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({ ... });Inspect bundle: Check
.next/directory for MSW stringsgrep -r "setupWorker\|HttpResponse\.json" .next/
Architecture Details
The adapter uses package.json conditional exports to provide different entry points per environment:
{
"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.
