Next.js Pages Router - Getting Started
Test your Next.js Pages Router application with API routes, getServerSideProps, and getStaticProps all executing. No mocking of Next.js internals required.
Installation
Section titled “Installation”npm install @scenarist/nextjs-adapternpm install -D @playwright/test @scenarist/playwright-helpers1. Define Scenarios
Section titled “1. Define Scenarios”import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/pages';
// ✅ RECOMMENDED - Default scenario with complete happy pathconst 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 userconst 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
Section titled “2. Set Up Scenarist”import { createScenarist } from '@scenarist/nextjs-adapter/pages';import { scenarios } from './scenarios';
export const scenarist = createScenarist({ enabled: process.env.NODE_ENV === 'test', scenarios,});3. Create Scenario Control Endpoint
Section titled “3. Create Scenario Control Endpoint”import { scenarist } from '@/lib/scenarist';
export default scenarist.createScenarioEndpoint();4. Set Up Playwright Fixtures
Section titled “4. Set Up Playwright Fixtures”import { withScenarios, expect } from '@scenarist/playwright-helpers';import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDsexport const test = withScenarios(scenarios);export { expect };5. Write Tests
Section titled “5. Write Tests”Testing Server-Side Rendering
Section titled “Testing Server-Side Rendering”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:
export async function getServerSideProps() { // This fetch is mocked by Scenarist const response = await fetch('https://api.stripe.com/v1/products', { headers: { 'Authorization': `Bearer ${process.env.STRIPE_KEY}` }, });
const { data: products } = await response.json();
return { props: { products } };}
export default function ProductsPage({ products }) { return ( <div> {products.map(product => ( <div key={product.id}> <h2>{product.name}</h2> <span className="price">${(product.price / 100).toFixed(2)}</span> </div> ))} </div> );}Testing API Routes
Section titled “Testing API Routes”test('processes payment via API route', async ({ page, switchScenario }) => { await switchScenario(page, 'success');
// Your API route validation runs const response = await page.request.post('/api/checkout', { data: { amount: 5000, token: 'tok_test' }, });
expect(response.status()).toBe(200); const data = await response.json(); expect(data.status).toBe('succeeded');});Example API Route:
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: { '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
Section titled “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
Section titled “API Routes”Use the getScenaristHeaders helper to safely extract Scenarist headers:
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
Section titled “getServerSideProps”The same helper works in getServerSideProps:
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 ( <div> {products.map(product => ( <div key={product.id}> <h2>{product.name}</h2> <span className="price">${(product.price / 100).toFixed(2)}</span> </div> ))} </div> );}The getScenaristHeaders helper extracts the test ID header (x-scenarist-test-id) for forwarding to external APIs.
What Makes Pages Router Setup Special
Section titled “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
Section titled “Next Steps”- Example App on GitHub → - Clone and run the complete working example
- Testing Database Apps → - Learn how to test Next.js apps that use both databases and external APIs
- Architecture → - Learn how Scenarist works under the hood