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 20+
- pnpm 9+
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: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 API route interceptionif (typeof window === 'undefined') { scenarist.start();}pages/api/__scenario__.ts - Scenario control endpoint
import { scenarist } from '@/lib/scenarist';export default scenarist.createScenarioEndpoint();This creates the /__scenario__ endpoint used by tests to switch scenarios.
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', 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: { status: 'pending', progress: 0 } }, { status: 200, body: { status: 'processing', progress: 50 } }, { status: 200, body: { status: 'complete', progress: 100 } } ], repeat: 'last' } }]}cartWithState - Stateful shopping cart
cartWithState: { id: 'cartWithState', mocks: [ { method: 'POST', url: 'http://localhost:3001/cart/add', captureState: { 'cartItems[]': 'body.productId' }, response: { status: 200, body: { success: true } } }, { method: 'GET', url: 'http://localhost:3001/cart', response: { status: 200, body: { items: '{{state.cartItems}}' } } } ]}checkout - Feature composition (matching + state)
checkout: { id: 'checkout', mocks: [ // UK free shipping { method: 'POST', url: 'http://localhost:3001/checkout/shipping', match: { body: { country: 'UK' } }, captureState: { country: 'body.country', address: 'body.address' }, response: { status: 200, body: { 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
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: { 'x-user-tier': tier }, });
const data = await response.json(); res.status(200).json(data);}pages/api/cart.ts - Cart API route with state capture
export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'POST') { // Add to cart - state captured by Scenarist const response = await fetch('http://localhost:3001/cart/add', { method: 'POST', body: JSON.stringify(req.body), }); return res.json(await response.json()); }
// Get cart - state injected by Scenarist const response = await fetch('http://localhost:3001/cart'); res.json(await response.json());}Test Examples
Section titled “Test Examples”tests/playwright/products-server-side.spec.ts - getServerSideProps with request matching
test('premium users see discounted pricing', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser');
await page.goto('/');
// getServerSideProps fetches from API route with x-user-tier header // Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } } await expect(page.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('/polling');
// First request: pending await expect(page.getByText('Status: pending')).toBeVisible();
await page.getByRole('button', { name: 'Refresh' }).click(); // Second request: processing await expect(page.getByText('Status: processing')).toBeVisible();
await page.getByRole('button', { name: 'Refresh' }).click(); // Third request: complete await expect(page.getByText('Status: complete')).toBeVisible();});tests/playwright/cart-server-side.spec.ts - Stateful mocks with API routes
test('cart maintains state across requests', async ({ page, switchScenario }) => { const testId = await switchScenario(page, 'cartWithState');
// Add product via API route - state captured const response = await page.request.post('http://localhost:3000/api/cart', { headers: { 'x-scenarist-test-id': testId }, data: { productId: 'prod-1' } });
expect(response.ok()).toBe(true);
await page.goto('/cart');
// Cart shows added product - state injected await expect(page.getByText('Product A')).toBeVisible();});tests/playwright/checkout.spec.ts - Feature composition
test('checkout captures address and applies UK free shipping', async ({ page, switchScenario }) => { const testId = await switchScenario(page, 'checkout');
// Calculate shipping - matches UK, captures address await page.request.post('http://localhost:3000/api/checkout/shipping', { headers: { 'x-scenarist-test-id': testId }, data: { country: 'UK', address: '123 Test St', city: 'London', postcode: 'SW1A 1AA' } });
// Place order - injects captured address const orderResponse = await page.request.post('http://localhost:3000/api/checkout/order', { headers: { 'x-scenarist-test-id': testId }, data: { orderId: 'order-123' } });
const order = await orderResponse.json();
// Verify state was captured and injected expect(order.shippingAddress.country).toBe('UK'); expect(order.shippingAddress.address).toBe('123 Test St');});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
- polling.tsx Polling example
Directoryapi/
- scenario .ts Scenarist endpoint
- products.ts Products API route
- cart.ts 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'); 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() { const response = await fetch('https://api.external.com/products'); 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