Skip to content

Next.js App Router - Getting Started

Test your Next.js App Router application with Server Components, Route Handlers, and Server Actions all executing. No mocking of Next.js internals required.

Terminal window
npm install @scenarist/nextjs-adapter msw
npm install -D @playwright/test @scenarist/playwright-helpers
lib/scenarios.ts
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Authenticated standard user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: {
status: 202,
body: { message_id: 'msg_123' },
},
},
],
};
// Specialized scenario: Override ONLY Auth0 for premium user
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
// Override: Auth0 returns premium tier
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
lib/scenarist.ts
import { createScenarist } from '@scenarist/nextjs-adapter/app';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Start MSW in Node.js environment
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
app/api/%5F%5Fscenario%5F%5F/route.ts
import { scenarist } from '@/lib/scenarist';
// scenarist is undefined in production builds or when enabled is false,
// so these handlers are undefined and the route answers 405
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;

To inspect captured state from your tests with debugState and waitForDebugState, add the debug state route the same way:

app/api/%5F%5Fscenarist%5F%5F/state/route.ts
import { scenarist } from '@/lib/scenarist';
export const GET = scenarist?.createStateEndpoint();

This route is served at /api/__scenarist__/state.

playwright.config.ts
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig<ScenaristOptions>({
use: {
baseURL: 'http://localhost:3000',
// The Playwright helpers default to /__scenarist__/state, which is the Express path
scenaristStateEndpoint: '/api/__scenarist__/state',
},
// Playwright starts the app with next dev, where Scenarist is active
webServer: {
// Next.js 16; on Next.js 14 and 15, drop --webpack (webpack is already the default)
command: 'npx next dev --webpack --port 3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

scenaristEndpoint defaults to /api/__scenario__, so you only need to set it if you moved the scenario route.

Run your scenario tests against next dev. The webServer block above starts it before the tests run and, outside CI, reuses one you already have running. Under next dev, createScenarist() returns the Scenarist instance, scenarist.start() starts MSW in the server process, and the scenario route switches scenarios. You don’t need to set NODE_ENV. The example app starts its server the same way, with next dev --webpack on Next.js 16. Next.js 14 and 15 have no --webpack flag and use webpack by default, so run next dev there.

Can the tests run against next build && next start? No. next build resolves the adapter’s production export condition, so createScenarist() returns undefined and MSW never starts. The scenario route then answers 405, and switchScenario throws Failed to switch scenario: 405. Setting NODE_ENV=test does not change this.

To test the production build itself, use a separate Playwright config that does not switch scenarios and points your app at real or stand-in backends. The example app does this with playwright.production.config.ts, which builds the app, runs next start, and asserts that the scenario route answers 405.

tests/fixtures.ts
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
tests/products.spec.ts
import { test, expect } from './fixtures'; // ✅ Import from fixtures
test('premium users see premium pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // ✅ Type-safe! Autocomplete works
await page.goto('/products');
// Your Server Component executes and renders
// Auth0 API returns premium tier, Stripe/SendGrid fall back to default
await expect(page.getByText('Premium Plan')).toBeVisible();
await expect(page.getByText('$50.00')).toBeVisible();
});
test('standard users see standard pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'default'); // Default scenario (happy path)
await page.goto('/products');
await expect(page.getByText('Standard Plan')).toBeVisible();
await expect(page.getByText('$25.00')).toBeVisible();
});

Example Server Component that the tests above exercise:

app/products/page.tsx
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
const plans = {
standard: { name: 'Standard Plan', price: '$25.00' },
premium: { name: 'Premium Plan', price: '$50.00' },
};
export default async function ProductsPage() {
// Scenarist answers this call with the Auth0 mock from the test's scenario
const response = await fetch('https://api.auth0.com/userinfo', {
// Forward the test ID, or the call falls back to the default scenario
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
cache: 'no-store',
});
const user = await response.json();
const plan = user.tier === 'premium' ? plans.premium : plans.standard;
return (
<div>
<h2>{plan.name}</h2>
<span className="price">{plan.price}</span>
</div>
);
}

Every fetch your server code makes to a mocked API must forward the Scenarist headers and set cache: 'no-store', so Next.js never answers it from its fetch cache instead of the mock for the active scenario (why). See Forwarding Headers to External APIs for Server Components and Route Handlers.

Why header forwarding matters: When your Server Components or Route Handlers call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use.

Server Components use headers() from next/headers, which returns ReadonlyHeaders (not a Request object). Use the getScenaristHeadersFromReadonlyHeaders helper:

app/products/page.tsx
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
// Get headers from Next.js Server Component
const headersList = await headers();
// Forward Scenarist headers to external API
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // ✅ For ReadonlyHeaders
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
cache: 'no-store', // Never serve a cached response to a different test
});
const { data: products } = await response.json();
return (
<div>
{products.map(product => (
<div key={product.id}>
<h2>{product.name}</h2>
<span className="price">${(product.price / 100).toFixed(2)}</span>
</div>
))}
</div>
);
}

Route Handlers have access to the Request object. Use the getScenaristHeaders helper:

app/api/products/route.ts
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
export async function GET(request: Request) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(request), // ✅ For Request objects
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
cache: 'no-store', // Never serve a cached response to a different test
});
const data = await response.json();
return Response.json(data);
}
ContextHelper FunctionImport
Server ComponentsgetScenaristHeadersFromReadonlyHeaders(headersList)@scenarist/nextjs-adapter/app
Route HandlersgetScenaristHeaders(request)@scenarist/nextjs-adapter/app

Both helpers extract the test ID header (x-scenarist-test-id) for forwarding to external APIs.

Server Components Actually Execute - Unlike traditional mocking, your React Server Components render and run your application logic.

Route Handlers Run Normally - Your validation, error handling, and business logic all execute.

Test Isolation - Each test gets isolated scenario state. Run tests in parallel with zero interference.

No App Restart - Switch scenarios instantly during test execution.