Next.js Pages Router: setting up Next.js Pages Router with API routes and getServerSideProps # Next.js > Using Scenarist with Next.js (App Router and Pages Router) ## Testing Next.js with Scenarist Scenarist provides first-class support for testing Next.js applications, addressing the challenges outlined in the [official Next.js testing documentation](https://nextjs.org/docs/app/building-your-application/testing). ### The Challenge Next.js recommends end-to-end testing for Server Components because *“async Server Components are new to the React ecosystem.”* Unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production execution. Traditional testing approaches face similar challenges across both routing paradigms: * Mocking framework internals creates distance from production behavior * Testing server-side logic requires complex setup * End-to-end tests are too slow for comprehensive scenario coverage ### How Scenarist Helps Scenarist enables testing Next.js applications through real HTTP requests: * Test server-side code without mocking framework internals * Verify different external API scenarios with runtime switching * Run parallel tests without interference * Switch scenarios per test against one running server, with no restarts * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## Next.js Support Scenarist supports both Next.js routing patterns: * **[App Router](/frameworks/nextjs-app-router/)** - Server Components, Server Actions, Route Handlers * **[Pages Router](/frameworks/nextjs-pages-router/)** - API Routes, getServerSideProps, getStaticProps ## Choose Your Router ### App Router (Modern) The App Router introduces React Server Components, allowing server-side rendering with direct data fetching. Scenarist enables testing these components without mocking Next.js internals. [**Get started with App Router →**](/frameworks/nextjs-app-router/) **Best for:** * New Next.js projects (Scenarist supports Next.js 14, 15 and 16) * Server Components and streaming * Server Actions for mutations * Route Handlers for API endpoints ### Pages Router (Traditional) The Pages Router uses the traditional pages directory with API routes and data fetching methods. Scenarist enables testing API routes and server-side rendering without complex mocking. [**Get started with Pages Router →**](/frameworks/nextjs-pages-router/) **Best for:** * Existing Next.js projects * Traditional API routes * getServerSideProps and getStaticProps * Gradual migration strategies ## What Makes Next.js Testing Different **Server-Side Execution** - Both routers execute code on the server that needs testing with different external API scenarios. **Framework Internals** - Traditional unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production. **Parallel Testing** - Scenarist’s test ID isolation allows concurrent tests with different scenarios, maintaining fast test execution. ## Next Steps Choose your routing paradigm to get started: * [App Router Guide →](/frameworks/nextjs-app-router/) * [Pages Router Guide →](/frameworks/nextjs-pages-router/) # Next.js Pages Router > Using Scenarist with Next.js Pages Router (API Routes, getServerSideProps, getStaticProps) ## Testing Next.js Pages Router with Scenarist The Pages Router is Next.js’s traditional routing system using the pages directory. Scenarist enables testing API routes and server-side rendering without complex mocking, allowing you to test your application code with real HTTP requests. ### The Challenge Testing Pages Router applications traditionally requires choosing between: * **Unit testing API routes** in isolation (misses integration with middleware and framework features) * **Mocking framework features** (getServerSideProps, request/response objects) which creates distance from production * **Testing each API route separately** with mocked dependencies (creates test maintenance burden) **Specific Pages Router challenges:** * API routes need testing with different external API scenarios * getServerSideProps executes server-side and requires mocking fetch or external APIs * getStaticProps needs testing with various data states * Framework internals must be mocked for unit tests ### How Scenarist Helps Scenarist enables HTTP-level testing for Pages Router applications: * **Test API routes** with real HTTP requests and different external API scenarios * **Test getServerSideProps** without mocking Next.js internals * **Test getStaticProps** against your default scenario (it has no request to carry a test ID) * **Run parallel tests** without interference * **No restarts** - each test switches scenario at runtime against one running server * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## Pages Router Features Supported ### API Routes Test API routes with different scenarios: pages/api/checkout.ts ```typescript import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages"; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { const response = await fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: getScenaristHeaders(req), // Forward the test ID body: JSON.stringify(req.body), }); const data = await response.json(); res.status(200).json(data); } // Test with different payment scenarios test("processes successful payment", async ({ page, switchScenario }) => { const testId = await switchScenario(page, "paymentSuccess"); const response = await page.request.post("/api/checkout", { headers: { "x-scenarist-test-id": testId }, // page.request needs the test ID explicitly data: { amount: 5000, token: "tok_test" }, }); expect(response.ok()).toBe(true); }); ``` ### getServerSideProps Test server-side rendering with different external API responses: pages/products.tsx ```typescript import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages'; export async function getServerSideProps(context) { const response = await fetch('https://api.stripe.com/v1/products', { headers: getScenaristHeaders(context.req), // Forward the test ID }); const { data: products } = await response.json(); return { props: { products } }; } export default function ProductsPage({ products }) { return ; } // Test without mocking Next.js internals test('renders products from external API', async ({ page, switchScenario }) => { await switchScenario(page, 'premiumUser'); await page.goto('/products'); await expect(page.getByText('Premium Product')).toBeVisible(); }); ``` ### getStaticProps `getStaticProps` receives no request object, so it cannot forward the test ID header. Its external calls resolve against the default test ID (`default-test`) and use your `default` scenario rather than a per-test scenario. In production builds it runs at build time: pages/index.tsx ```typescript export async function getStaticProps() { const response = await fetch('https://api.products.example.com/featured'); const { data: featured } = await response.json(); return { props: { featured }, revalidate: 60 // ISR }; } export default function HomePage({ featured }) { return ; } // Test against the default scenario's featured products test('renders featured products', async ({ page }) => { await page.goto('/'); await expect(page.getByText('Featured')).toBeVisible(); }); ``` ## Getting Started Ready to integrate Scenarist into your Next.js Pages Router application? [**Get started with Pages Router →**](/frameworks/nextjs-pages-router/getting-started/) ## Key Benefits **API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production. **Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. **Real HTTP Requests** - Tests make actual HTTP requests to your Next.js app, exercising middleware and routing. ## Next Steps * [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Set up Scenarist in your Pages Router app * [Next.js General Guide →](/frameworks/nextjs/) - Learn about Next.js testing with Scenarist # Next.js Pages Router Example App > Working example demonstrating Scenarist with Next.js Pages Router ## 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](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example) ## What It Demonstrates This example app showcases all major Scenarist features with Pages Router: ### 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 * **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 ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install ```bash # 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 ``` ## Running the Example ### Development Mode ```bash # Start the Next.js dev server pnpm dev ``` Visit to see the app. ### Run Tests ```bash # Run all tests pnpm test # Run tests in UI mode pnpm test:e2e:ui # Run specific test file pnpm test products-server-side ``` ## Key Files ### Scenarist Setup **`lib/scenarist.ts`** - Scenarist configuration ```typescript 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 ```typescript 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. ### Scenario Definitions **`lib/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-pages-router-example/lib/scenarios.ts)) Key scenarios: **`default`** - Default baseline behavior **`premiumUser`** - Premium tier with request matching ```typescript 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) ```typescript 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 ```typescript 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) ```typescript 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 **`pages/api/products.ts`** - Products API route with tier-based pricing ```typescript 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) ```typescript 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 **`tests/playwright/products-server-side.spec.ts`** - getServerSideProps with request matching ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ### How It Works 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 ### Test Isolation 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 ### File Structure ## Common Patterns ### Testing API Routes ```typescript // 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' }); }); ``` ### Testing getServerSideProps ```typescript // 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(); }); ``` ### Testing Request Matching Use request content to determine response: ```typescript // 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') } } }] ``` ### Testing Polling Scenarios Use sequences to simulate async operations: ```typescript // 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 } }] ``` ## Next Steps * [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Integrate Scenarist into your Pages Router app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection # Next.js Pages Router - Getting Started > Set up Scenarist with Next.js Pages Router in 5 minutes Test your Next.js Pages Router application with API routes, getServerSideProps, and getStaticProps all executing. No mocking of Next.js internals required. ## Installation ```bash npm install @scenarist/nextjs-adapter msw npm install -D @playwright/test @scenarist/playwright-helpers ``` `msw` (v2) is a required peer dependency of `@scenarist/nextjs-adapter`, which supports Next.js 14, 15 and 16. ## 1. Define Scenarios lib/scenarios.ts ```typescript import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/pages'; // ✅ RECOMMENDED - Default scenario with complete happy path const 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 user const 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 lib/scenarist.ts ```typescript import { createScenarist } from '@scenarist/nextjs-adapter/pages'; import { scenarios } from './scenarios'; export const scenarist = createScenarist({ enabled: true, scenarios, }); // Start MSW in the Next.js server process (scenarist is undefined in production builds) if (typeof window === 'undefined' && scenarist) { scenarist.start(); } ``` Create One Instance Use the `export const scenarist` pattern shown above, and import `scenarist` wherever you need it. Call `createScenarist()` in one module only. **Why this matters:** Scenarist handles a Next.js-specific issue for you. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. Without protection, this causes: * `[MSW] Multiple handlers with the same URL` warnings * Intermittent 500 errors from MSW * Different tests getting wrong scenarios * Scenarios not switching properly **How Scenarist solves this:** The first `createScenarist()` call stores its instance in `global.__scenarist_instance_pages`. Every later call returns that same instance, even when Next.js loads your module more than once, so only one instance and one MSW server ever exist. Later calls ignore the options you pass them, so a second `createScenarist()` call with different scenarios has no effect. The exception is `enabled: false`, which always returns `undefined`. ## 3. Create Scenario Control Endpoint pages/api/\_\_scenario\_\_.ts ```typescript import type { NextApiRequest, NextApiResponse } from 'next'; import { scenarist } from '@/lib/scenarist'; // scenarist is undefined in production builds or when enabled is false, and // Next.js requires a default export function, so fall back to a handler that answers 405 export default scenarist?.createScenarioEndpoint() ?? ((_req: NextApiRequest, res: NextApiResponse) => { res.status(405).end(); }); ``` ## 4. Set Up Playwright Fixtures tests/fixtures.ts ```typescript import { withScenarios, expect } from '@scenarist/playwright-helpers'; import { scenarios } from '../lib/scenarios'; // Create type-safe test object with scenario IDs export const test = withScenarios(scenarios); export { expect }; ``` ## 5. Write Tests ### Testing Server-Side Rendering tests/products.spec.ts ```typescript 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 getServerSideProps runs, Auth0 returns premium tier 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 Page with getServerSideProps:** pages/products.tsx ```typescript import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages'; export async function getServerSideProps({ req }) { // This fetch is mocked by Scenarist (the forwarded test ID selects the scenario) const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeaders(req), 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, }); const { data: products } = await response.json(); return { props: { products } }; } export default function ProductsPage({ products }) { return (
{products.map(product => (

{product.name}

${(product.price / 100).toFixed(2)}
))}
); } ``` ### Testing API Routes tests/checkout.spec.ts ```typescript test('processes payment via API route', async ({ page, switchScenario }) => { const testId = await switchScenario(page, 'default'); // Your API route validation runs (page.request needs the test ID explicitly) const response = await page.request.post('/api/checkout', { headers: { 'x-scenarist-test-id': testId }, data: { amount: 5000, token: 'tok_test' }, }); expect(response.status()).toBe(200); const data = await response.json(); expect(data.status).toBe('succeeded'); }); ``` **Example API Route:** pages/api/checkout.ts ```typescript import type { NextApiRequest, NextApiResponse } from 'next'; import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Validation runs normally if (!req.body.amount || !req.body.token) { return res.status(400).json({ error: 'Missing required fields' }); } // External API call is mocked by Scenarist const response = await fetch('https://api.stripe.com/v1/charges', { method: 'POST', headers: { ...getScenaristHeaders(req), 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(req.body), }); const data = await response.json(); res.status(200).json(data); } ``` ## Forwarding Headers to External APIs **Why header forwarding matters:** When your API routes or getServerSideProps call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use. ### API Routes Use the `getScenaristHeaders` helper to safely extract Scenarist headers: pages/api/products.ts ```typescript import type { NextApiRequest, NextApiResponse } from 'next'; import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Forward Scenarist headers to external API const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeaders(req), 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, }); const data = await response.json(); res.status(200).json(data); } ``` ### getServerSideProps The same helper works in getServerSideProps: pages/products.tsx ```typescript import type { GetServerSideProps } from 'next'; import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages'; export const getServerSideProps: GetServerSideProps = async ({ req }) => { const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeaders(req), 'Authorization': `Bearer ${process.env.STRIPE_KEY}`, }, }); const { data: products } = await response.json(); return { props: { products } }; }; export default function ProductsPage({ products }) { return (
{products.map(product => (

{product.name}

${(product.price / 100).toFixed(2)}
))}
); } ``` The `getScenaristHeaders` helper extracts the test ID header (`x-scenarist-test-id`) for forwarding to external APIs. ## What Makes Pages Router Setup Special **API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production. **Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs. **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 * **[Example App on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example)** - Clone and run the complete working example * **[Testing Database Apps →](/guides/testing-database-apps/)** - Learn how to test Next.js apps that use both databases and external APIs * **[Architecture →](/concepts/architecture/)** - Learn how Scenarist works under the hood