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.
Installation
Section titled “Installation”npm install @scenarist/nextjs-adapternpm install -D @playwright/test @scenarist/playwright-helpers1. Define Scenarios
Section titled “1. Define Scenarios”import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// ✅ RECOMMENDED - Default scenario with complete happy pathconst 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 userconst 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;2. Set Up Scenarist
Section titled “2. Set Up Scenarist”import { createScenarist } from '@scenarist/nextjs-adapter/app';import { scenarios } from './scenarios';
export const scenarist = createScenarist({ enabled: process.env.NODE_ENV === 'test', scenarios,});3. Create Scenario Control Endpoint
Section titled “3. Create Scenario Control Endpoint”import { scenarist } from '@/lib/scenarist';
export const POST = scenarist.createScenarioEndpoint();export const GET = scenarist.createScenarioEndpoint();4. Set Up Playwright Fixtures
Section titled “4. Set Up Playwright Fixtures”import { withScenarios, expect } from '@scenarist/playwright-helpers';import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDsexport const test = withScenarios(scenarios);export { expect };5. Write Tests
Section titled “5. Write Tests”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:
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> );}Forwarding Headers to External APIs
Section titled “Forwarding Headers to External APIs”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 (ReadonlyHeaders)
Section titled “Server Components (ReadonlyHeaders)”Server Components use headers() from next/headers, which returns ReadonlyHeaders (not a Request object). Use the getScenaristHeadersFromReadonlyHeaders helper:
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 (Request object)
Section titled “Route Handlers (Request object)”Route Handlers have access to the Request object. Use the getScenaristHeaders helper:
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);}When to use which helper
Section titled “When to use which helper”| Context | Helper Function | Import |
|---|---|---|
| Server Components | getScenaristHeadersFromReadonlyHeaders(headersList) | @scenarist/nextjs-adapter/app |
| Route Handlers | getScenaristHeaders(request) | @scenarist/nextjs-adapter/app |
Both helpers extract the test ID header (x-scenarist-test-id) for forwarding to external APIs.
What Makes App Router Setup Special
Section titled “What Makes App Router Setup Special”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.
Next Steps
Section titled “Next Steps”- Example App on GitHub → - Clone and run the complete working example
- Testing Database Apps → - Learn how to test Next.js apps that use both databases and external APIs
- Architecture → - Learn how Scenarist works under the hood