npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@scenarist/nextjs-adapter

v0.4.10

Published

Next.js adapter for Scenarist (Pages Router and App Router)

Readme

@scenarist/nextjs-adapter

npm version License: MIT

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 conflict

With 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 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

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:

| 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 test

Testing 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 screens

Testing 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 IDs

Want 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 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.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 wrap createScenarist() 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 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. 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 at http://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:


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

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.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:

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

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-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 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:

  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

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:

  • enabled is conditional (never hardcoded true)
  • ✅ 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 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.

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/unknown

JSON 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 server

Tests 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 works

TypeScript 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-shaking

Bundle 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:treeshaking

This 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:

  1. Check NODE_ENV: Ensure NODE_ENV=production during build

    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

    // ❌ 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

    grep -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

Note: The MSW adapter is used internally by this package. Users of @scenarist/nextjs-adapter don't need to interact with it directly.