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
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: process.env.NODE_ENV === 'test',
scenarios,
});
app/api/%5F%5Fscenario%5F%5F/route.ts
import { scenarist } from '@/lib/scenarist';
export const POST = scenarist.createScenarioEndpoint();
export const GET = scenarist.createScenarioEndpoint();
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:

app/products/page.tsx
export default async function ProductsPage() {
// This fetch is mocked by Scenarist
const response = await fetch('https://api.stripe.com/v1/products', {
headers: { 'Authorization': `Bearer ${process.env.STRIPE_KEY}` },
});
const { data: products } = await response.json();
// Your component renders with mocked data
return (
<div>
{products.map(product => (
<div key={product.id}>
<h2>{product.name}</h2>
<span className="price">${(product.price / 100).toFixed(2)}</span>
</div>
))}
</div>
);
}

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}`,
},
});
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}`,
},
});
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.