Next.js Pages Router Example App
Overview
Section titled “Overview”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
What It Demonstrates
Section titled “What It Demonstrates”This example app showcases all major Scenarist features with Pages Router:
Core Features
Section titled “Core Features”- 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
Dynamic Response Features
Section titled “Dynamic Response Features”- 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
Installation
Section titled “Installation”Prerequisites
Section titled “Prerequisites”- Node.js 22+
- pnpm 11+
Clone and Install
Section titled “Clone and Install”# Clone the repositorygit clone https://github.com/citypaul/scenarist.gitcd scenarist
# Install dependenciespnpm install
# Navigate to Pages Router examplecd apps/nextjs-pages-router-exampleRunning the Example
Section titled “Running the Example”Development Mode
Section titled “Development Mode”# Start the Next.js dev serverpnpm devVisit http://localhost:3000 to see the app.
Run Tests
Section titled “Run Tests”# Run all testspnpm test
# Run tests in UI modepnpm test:e2e:ui
# Run specific test filepnpm test products-server-sideKey Files
Section titled “Key Files”Scenarist Setup
Section titled “Scenarist Setup”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 handlerexport 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.
Scenario Definitions
Section titled “Scenario Definitions”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}}' } } } } ]}API Route Examples
Section titled “API Route Examples”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.
Test Examples
Section titled “Test Examples”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');});Architecture
Section titled “Architecture”How It Works
Section titled “How It Works”- Setup - Pages Router app includes Scenarist API endpoint
- Test starts - Calls
switchScenario()to set active scenario - HTTP request - Test makes request to Next.js page or API route
- Route execution - API routes and getServerSideProps execute normally
- External API call - Intercepted by MSW with scenario-defined response
- Test assertion - Verifies API response or rendered output
Test Isolation
Section titled “Test Isolation”Each test gets a unique test ID:
- Scenario switching:
POST /api/__scenario__withx-scenarist-test-idheader - All requests include
x-scenarist-test-idheader automatically (Playwright helper) - Server routes requests to correct scenario based on test ID
- Parallel tests don’t interfere with each other
File Structure
Section titled “File Structure”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
Common Patterns
Section titled “Common Patterns”Testing API Routes
Section titled “Testing API Routes”// API route with external API callexport 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 scenariostest('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' });});Testing getServerSideProps
Section titled “Testing getServerSideProps”// Page with getServerSidePropsexport 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 scenariostest('renders products from getServerSideProps', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products'); await expect(page.getByText('Premium Product')).toBeVisible();});Testing Request Matching
Section titled “Testing Request Matching”Use request content to determine response:
// Scenario with tier-based pricingmocks: [{ 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') } }}]Testing Polling Scenarios
Section titled “Testing Polling Scenarios”Use sequences to simulate async operations:
// Scenario with polling sequencemocks: [{ 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 }}]Next Steps
Section titled “Next Steps”- Pages Router Getting Started → - Integrate Scenarist into your Pages Router app
- Request Matching → - Learn about request content matching
- Sequences → - Learn about response sequences
- Stateful Mocks → - Learn about state capture and injection