Skip to content

Next.js Pages Router Example App

The Next.js Pages Router example demonstrates HTTP-level testing for API routes, getServerSideProps, and client components using Scenarist.

GitHub: apps/nextjs-pages-router-example

This example app showcases all major Scenarist features with Pages Router:

  • API Routes - Test Next.js API routes with different external API responses
  • getServerSideProps - Test server-side rendering with mocked external APIs
  • Client Components - Test client-side hydration with backend scenarios
  • 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
  • Feature Composition - Checkout flow combining matching and stateful mocks
  • 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 Pages Router example
cd apps/nextjs-pages-router-example
Terminal window
# Start the Next.js dev server
pnpm dev

Visit http://localhost:3000 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-side

lib/scenarist.ts - Scenarist configuration

import { createScenarist } from '@scenarist/nextjs-adapter/pages';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Auto-start MSW server for server-side interception (scenarist is undefined in production builds)
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}

pages/api/__scenario__.ts - Scenario control endpoint

import type { NextApiRequest, NextApiResponse } from 'next';
import { scenarist } from '../../lib/scenarist';
// scenarist is undefined in production builds or when enabled is false - fall back to a 405 handler
export default scenarist?.createScenarioEndpoint() ??
((_req: NextApiRequest, res: NextApiResponse) => {
res.status(405).end();
});

This creates the /api/__scenario__ endpoint used by tests to switch scenarios. pages/api/__scenarist__/state.ts exports scenarist?.createStateEndpoint() for the /api/__scenarist__/state debug endpoint.

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

Key scenarios:

default - Default baseline behavior

premiumUser - Premium tier with request matching

premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier pricing (£99.99)',
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',
name: 'GitHub Job Polling',
description: "Async job polling sequence (repeat: 'last')",
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: [
// (also mocks GET /products so the add-to-cart buttons render)
{
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}}' } }
}
]
}

checkout - Feature composition (matching + state)

checkout: {
id: 'checkout',
name: 'Checkout with Shipping',
description: 'Demonstrates matching + stateful composition',
mocks: [
// UK free shipping (US and FR variants follow the same shape)
{
method: 'POST',
url: 'http://localhost:3001/checkout/shipping',
match: { body: { country: 'UK' } },
captureState: { country: 'body.country', address: 'body.address' },
response: { status: 200, body: { country: 'UK', shippingCost: 0 } }
},
// Order with captured address
{
method: 'POST',
url: 'http://localhost:3001/checkout/order',
response: {
status: 200,
body: {
shippingAddress: {
country: '{{state.country}}',
address: '{{state.address}}'
}
}
}
}
]
}

pages/api/products.ts - Products API route with tier-based pricing

import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const tier = req.headers['x-user-tier'] || 'standard';
// This fetch is mocked by Scenarist
const response = await fetch('http://localhost:3001/products', {
headers: {
...getScenaristHeaders(req), // Forward the test ID
'x-user-tier': tier,
},
});
const data = await response.json();
res.status(200).json(data);
}

pages/api/cart/add.ts - Add-to-cart API route (state captured)

import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// GET current cart - state injected by Scenarist
const current = await fetch('http://localhost:3001/cart', {
headers: getScenaristHeaders(req),
}).then((r) => r.json());
// PATCH full items array - state captured by Scenarist
const response = await fetch('http://localhost:3001/cart', {
method: 'PATCH',
headers: { ...getScenaristHeaders(req), 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [...(current.items || []), req.body.productId], // raw productIds
}),
});
const data = await response.json();
res.status(200).json({ success: true, items: data.items });
}

pages/api/cart.ts serves GET /api/cart the same way, forwarding getScenaristHeaders(req) to GET http://localhost:3001/cart.

tests/playwright/products-server-side.spec.ts - getServerSideProps with request matching

test('should render premium products server-side', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/?tier=premium');
// getServerSideProps fetches http://localhost:3001/products with x-user-tier header
// Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } }
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('£99.99')).toBeVisible();
});

tests/playwright/sequences.spec.ts - Polling with sequences

test('polling updates status through sequence', async ({ page, switchScenario }) => {
await switchScenario(page, 'githubPolling');
await page.goto('/sequences');
const checkStatus = page.getByRole('button', { name: 'Check Job Status' });
const jobStatus = page.getByRole('status').first();
// First request: pending
await checkStatus.click();
await expect(jobStatus).toContainText('Status: pending');
// Second request: processing
await checkStatus.click();
await expect(jobStatus).toContainText('Status: processing');
// Third request: complete
await checkStatus.click();
await expect(jobStatus).toContainText('Status: complete');
});

tests/playwright/cart-server-side.spec.ts - Stateful mocks with API routes

test('should render cart items server-side after adding products', async ({ page, switchScenario }) => {
await switchScenario(page, 'cartWithState');
// Add products via the UI - POST /api/cart/add, state captured
await page.goto('/');
const addButtons = page.getByRole('button', { name: /Add .* to cart/ });
const cartCount = page.getByLabel('Cart item count');
await addButtons.nth(0).click();
await expect(cartCount).toHaveText('1');
await addButtons.nth(1).click();
await expect(cartCount).toHaveText('2');
// Cart page is server-rendered - state injected
await page.goto('/cart');
const cartList = page.getByRole('list', { name: 'Shopping cart items' });
await expect(cartList.getByRole('listitem')).toHaveCount(2);
});

tests/playwright/checkout.spec.ts - Feature composition

test('should provide free shipping for UK and capture address', async ({ page, switchScenario }) => {
await switchScenario(page, 'checkout');
await page.goto('/checkout');
// Calculate shipping - matches UK, captures address
await page.getByLabel('Country').selectOption('UK');
await page.getByLabel('Address').fill('123 Test Street');
await page.getByLabel('City').fill('London');
await page.getByLabel('Postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Calculate Shipping' }).click();
const shippingCost = page.getByRole('status').filter({ hasText: 'Shipping' });
await expect(shippingCost).toContainText('£0.00');
// Place order - injects captured address
await page.getByRole('button', { name: 'Place Order' }).click();
const confirmation = page.getByRole('region', { name: 'Order confirmation' });
await expect(confirmation).toContainText('123 Test Street');
await expect(confirmation).toContainText('UK');
});
  1. Setup - Pages Router app includes Scenarist API endpoint
  2. Test starts - Calls switchScenario() to set active scenario
  3. HTTP request - Test makes request to Next.js page or API route
  4. Route execution - API routes and getServerSideProps execute normally
  5. External API call - Intercepted by MSW with scenario-defined response
  6. Test assertion - Verifies API response or rendered output

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-pages-router-example/
    • Directorypages/
      • index.tsx Products page (getServerSideProps)
      • cart.tsx Cart page
      • sequences.tsx Sequences (polling) example
      • checkout.tsx Checkout page
      • Directoryapi/
        • scenario .ts Scenarist endpoint
        • scenarist /state.ts Debug state endpoint
        • products.ts Products API route
        • cart.ts Cart API route
        • cart/add.ts Add-to-cart API route
        • Directorycheckout/ Checkout API routes
          • …
    • Directorylib/
      • scenarist.ts Scenarist setup
      • scenarios.ts Scenario definitions
    • Directorytests/
      • Directoryplaywright/
        • products-server-side.spec.ts
        • sequences.spec.ts
        • cart-server-side.spec.ts
        • checkout.spec.ts
// API route with external API call
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await fetch('https://api.external.com/data', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const data = await response.json();
res.json(data);
}
// Test with different scenarios
test('standard data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'default');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'standard' });
});
test('premium data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'premium' });
});
// Page with getServerSideProps
export async function getServerSideProps({ req }) {
const response = await fetch('https://api.external.com/products', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const { products } = await response.json();
return { props: { products } };
}
// Test with different data scenarios
test('renders products from getServerSideProps', 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: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}, {
method: 'GET',
url: 'http://localhost:3001/products',
// No match criteria - fallback for standard tier
response: { status: 200, body: { products: buildProducts('standard') } }
}]

Use sequences to simulate async operations:

// Scenario with polling sequence
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
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
}
}]