Skip to content

Next.js Example App

The Next.js App Router example demonstrates HTTP-level testing for Server Components, Client Components, API routes, and Server Actions using Scenarist.

GitHub: apps/nextjs-app-router-example

This example app showcases all major Scenarist features:

  • Server Components - Test async Server Components without mocking Next.js internals
  • Client Components - Test client-side hydration with backend scenarios
  • API Routes - Test Route Handlers with different external API responses
  • Runtime Scenario Switching - Multiple scenarios running concurrently
  • Request Matching - Different responses based on request content (tier-based pricing)
  • Sequences - Polling scenarios (pending → processing → complete)
  • Stateful Mocks - Shopping cart with state capture and injection
  • Node.js 22+
  • pnpm 11+
Terminal window
# Clone the repository
git clone https://github.com/citypaul/scenarist.git
cd scenarist
# Install dependencies
pnpm install
# Navigate to Next.js example
cd apps/nextjs-app-router-example
Terminal window
# Start the Next.js dev server
pnpm dev

Visit http://localhost:3002 to see the app.

Terminal window
# Run all tests
pnpm test
# Run tests in UI mode
pnpm test:e2e:ui
# Run specific test file
pnpm test products-server-components

lib/scenarist.ts - Scenarist configuration

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 - Scenario control endpoint

import { scenarist } from '../../../lib/scenarist';
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;

This creates the /api/__scenario__ endpoint used by tests to switch scenarios.

lib/scenarios.ts - All scenario definitions (view on GitHub)

Key scenarios:

default - Standard user, successful API responses

premiumUser - Premium tier with request matching

premiumUser: {
id: 'premiumUser',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}]
}

githubPolling - Polling sequence (pending → processing → complete)

githubPolling: {
id: 'githubPolling',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { jobId: '123', status: 'pending', progress: 0 } },
{ status: 200, body: { jobId: '123', status: 'processing', progress: 50 } },
{ status: 200, body: { jobId: '123', status: 'complete', progress: 100 } }
],
repeat: 'last'
}
}]
}

cartWithState - Stateful shopping cart

cartWithState: {
id: 'cartWithState',
name: 'Shopping Cart with State',
description: 'Stateful shopping cart that captures and injects cart items',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/cart',
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
},
{
method: 'PATCH',
url: 'http://localhost:3001/cart',
captureState: {
cartItems: 'body.items'
},
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
}
]
}

tests/playwright/products-server-components.spec.ts - Server Components with request matching

test('should render products from server component with premium tier', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products?tier=premium');
// Server Component forwards x-user-tier: premium to the products API
// Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } }
await expect(page.getByText('Current tier: premium')).toBeVisible();
await expect(page.getByText('£99.99')).toBeVisible();
});

tests/playwright/polling-server-components.spec.ts - Polling with sequences

test('should show complete status on third request', async ({ page, switchScenario }) => {
await switchScenario(page, 'githubPolling');
// First request: pending
await page.goto('/polling?jobId=123');
// Second request: processing
await page.reload();
// Third request: complete
await page.reload();
await expect(page.getByText('COMPLETE', { exact: true })).toBeVisible();
await expect(page.getByText('100%', { exact: true })).toBeVisible();
});

tests/playwright/cart-server-components.spec.ts - Stateful mocks with Server Components

test('should display cart item after adding product via state capture', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'cartWithState');
// Add product - state captured
await page.request.post('http://localhost:3002/api/cart/add', {
headers: { 'x-scenarist-test-id': testId },
data: { productId: 'prod-1' }
});
await page.goto('/cart-server');
// Cart shows added product - state injected
await expect(page.getByText('Product A')).toBeVisible();
});
  1. Setup - Next.js app includes the Scenarist scenario endpoint route
  2. Test starts - Calls switchScenario() to set active scenario
  3. HTTP request - Test makes request to Next.js app
  4. Backend execution - Server Components, API routes execute normally
  5. External API call - Intercepted by MSW with scenario-defined response
  6. Test assertion - Verifies rendered output or API response

Each test gets a unique test ID:

  • Scenario switching: POST /api/__scenario__ with x-scenarist-test-id header
  • All requests include x-scenarist-test-id header automatically (Playwright helper)
  • Server routes requests to correct scenario based on test ID
  • Parallel tests don’t interfere with each other
  • Directoryapps/nextjs-app-router-example/
    • Directoryapp/
      • Directoryapi/
        • %5F%5Fscenario%5F%5F/route.ts Scenario endpoint
        • %5F%5Fscenarist%5F%5F/state/route.ts Debug state endpoint
      • Directoryproducts/ Server Components
        • …
      • Directorypolling/ Sequence example
        • …
      • Directorycart-server/ Stateful mock example
        • …
    • Directorylib/
      • scenarist.ts Scenarist setup
      • scenarios.ts Scenario definitions
    • Directorytests/
      • Directoryplaywright/
        • products-server-components.spec.ts
        • polling-server-components.spec.ts
        • cart-server-components.spec.ts
// Server Component fetches external API, forwarding the test ID
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
const response = await fetch('https://api.products.example.com/list', {
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
cache: 'no-store',
});
const products = await response.json();
return <div>{products.map(p => <Product key={p.id} {...p} />)}</div>;
}
// Test with different scenarios
test('standard products', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/products');
await expect(page.getByText('Product A')).toBeVisible();
});
test('premium products', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});

Use request content to determine response:

// Scenario with tier-based pricing
mocks: [{
method: 'GET',
url: 'https://api.products.example.com/pricing',
match: { query: { tier: 'premium' } },
response: { status: 200, body: { price: 799, discount: 20 } }
}, {
method: 'GET',
url: 'https://api.products.example.com/pricing',
// No match criteria - fallback for standard tier
response: { status: 200, body: { price: 999, discount: 0 } }
}]

Use sequences to simulate async operations:

// Scenario with polling sequence
mocks: [{
method: 'GET',
url: 'https://api.github.com/repos/user/repo/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // After sequence exhausts, repeat last response
}
}]