When you deploy a Next.js application to production, your users interact with a fully built and running server. Your end-to-end tests should do the same. Testing Next.js apps with Playwright: a practical guide means setting up realistic conditions—starting the production build, handling server components properly, mocking at the right layer, and preserving authentication state across test runs.
In this guide, we'll walk through the key decisions and configurations that let your team run confident, maintainable tests against App Router projects without flaking or lengthy debug cycles.
webServer — Start next build && next start for realistic runs
The most critical difference between testing Next.js in development and production is how the framework renders and serves pages. When you run next dev, hot module replacement and incremental builds mask real bottlenecks and bugs that only surface under next build && next start.
Playwright's webServer configuration starts your production build once before all tests run, keeping tests fast and your local machine responsive.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run build && npm start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Set reuseExistingServer: true during local development so you don't rebuild on every test run. In CI, set it to false to ensure a clean build each time. The timeout: 120000 gives your build pipeline breathing room; adjust based on your app's complexity.
This approach catches real issues: middleware bugs, environment variable misconfigurations, or static generation problems that dev mode skips over.
Server components — Test rendered output, not implementation
Next.js 13+ App Router heavily favors server components by default. Your tests should verify what users see in the DOM, not how data fetching happens internally. This distinction keeps tests stable as you refactor implementations.
When testing a page built with server components, you're testing the final HTML and any interactive client-side JavaScript. Playwright runs in the browser, so it naturally works with rendered output.
// tests/e2e/products.spec.ts
import { test, expect } from '@playwright/test';
test('products page displays list with prices', async ({ page }) => {
// Navigate to the page built and served by Next.js
await page.goto('/products');
// Wait for and verify the rendered list
await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
// Check that product cards exist and contain expected content
const productCards = page.locator('[data-testid="product-card"]');
await expect(productCards).toHaveCount(12);
// Verify a specific product shows price and description
const firstCard = productCards.first();
await expect(firstCard.locator('[data-testid="product-price"]')).toContainText('$');
await expect(firstCard.locator('[data-testid="product-name"]')).toBeVisible();
});
test('products can be sorted by price', async ({ page }) => {
await page.goto('/products');
// Interact with client-side sort control
await page.selectOption('[data-testid="sort-select"]', 'price-asc');
// Verify the DOM reflects the new order
const prices = await page.locator('[data-testid="product-price"]').allTextContents();
const numericPrices = prices.map(p => parseFloat(p.replace('$', '')));
// Simple check: each price is <= the next
for (let i = 0; i < numericPrices.length - 1; i++) {
expect(numericPrices[i]).toBeLessThanOrEqual(numericPrices[i + 1]);
}
});
Focus on user-facing behavior: visible text, clickable elements, form submissions, and navigation. Avoid testing internal state management or hook logic—those belong in unit tests. This keeps your E2E suite lean and resilient to refactors.
Mock external APIs — Route requests from the browser; mock server calls separately
Next.js server components often fetch from external APIs directly. You need to mock those calls, but at the right layer. Mocking at the HTTP level (via Playwright's network interception) only intercepts browser requests. Server-side fetches bypass the browser entirely.
The solution: mock server-side API calls using environment variables or a test-mode build, and use Playwright's route interception for any client-side fetches.
// app/products/page.tsx (server component)
async function getProducts() {
// Uses environment variable; in tests, this points to mock/test data
const apiUrl = process.env.API_URL || 'https://api.example.com';
const res = await fetch(`${apiUrl}/products`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
const products = await res.json();
return products;
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div data-testid="product-list">
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// playwright.config.ts — mock server calls via environment variables
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run build && npm start',
env: {
// Point server-side fetches to a local mock or test API
API_URL: 'http://localhost:3001',
API_KEY: 'test-key-12345',
NODE_ENV: 'production',
},
},
});
// tests/e2e/products.spec.ts — mock client-side requests via route interception
import { test, expect } from '@playwright/test';
test('client-side search mocks API responses', async ({ page }) => {
// Intercept client-side API calls
await page.route('**/api/search**', route => {
route.abort('blockedbyclient');
});
// Or return a mock response
await page.route('**/api/search**', route => {
route.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
results: [
{ id: 1, name: 'Widget Pro', price: 99.99 },
{ id: 2, name: 'Widget Lite', price: 49.99 },
],
}),
});
});
await page.goto('/products');
await page.fill('[data-testid="search-input"]', 'widget');
await page.click('[data-testid="search-button"]');
await expect(page.locator('text=Widget Pro')).toBeVisible();
});
Keep your test server lightweight. If your real API is complex, stand up a small Node.js mock server on port 3001 that your tests point to, or use a service like Prism that auto-generates OpenAPI mocks.
Auth — Save storage state after login
Many test suites need to run as an authenticated user. Rather than logging in before every test, save the authenticated session state once and reuse it across tests. This speeds up your suite significantly and reduces flakiness from login failures.
// tests/e2e/auth.setup.ts — global setup that runs once before all tests
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Perform login flow
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'test-password-123');
await page.click('[data-testid="login-button"]');
// Wait for redirect to authenticated page
await page.waitForURL('/dashboard');
// Save storage state (cookies, localStorage, sessionStorage)
await page.context().storageState({ path: authFile });
});
// playwright.config.ts — use saved auth state in all tests
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
webServer: {
command: 'npm run build && npm start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Load storage state before each test in this project
storageState: 'playwright/.auth/user.json',
},
},
{
name: 'auth setup',
testMatch: /.*\.setup\.ts/,
},
],
// Run auth setup before other tests
fullyParallel: false,
});
// tests/e2e/dashboard.spec.ts — tests automatically use saved auth
import { test, expect } from '@playwright/test';
test('dashboard loads user data', async ({ page }) => {
// Page context already has auth cookies/storage
await page.goto('/dashboard');
await expect(page.locator('[data-testid="user-name"]')).toContainText('Test User');
await expect(page.locator('[data-testid="user-email"]')).toContainText('test@example.com');
});
test('user can navigate to settings', async ({ page }) => {
await page.goto('/dashboard');
await page.click('[data-testid="settings-link"]');
await expect(page).toHaveURL('/dashboard/settings');
});
Keep your auth setup file separate and mark it so Playwright runs it as a prerequisite. This pattern scales: create multiple auth states (admin, user, guest) and reference them in different test projects.
Common mistakes
- Using next dev for tests: Development mode hides real bugs. Always test against
next build && next start. - Mocking server-side fetches at the browser level: Server components run before the page reaches the browser. Mock via environment variables or a local test API instead.
- Testing implementation details: Avoid checking internal state or hook calls. Focus on what the user sees and interacts with.
- Re-logging in for each test: Save and reuse auth state. Logging in repeatedly multiplies test time and failure points.
- Ignoring the build step: Skipping
next buildduring local development tempts you to test broken code. Include it in your config and workflow. - Not handling Next.js redirects: Server components and middleware often redirect. Use
page.waitForURL()to wait for the final destination.
Key takeaways
- Configure
webServerto runnext build && npm startso tests run against production-like conditions. - Test the rendered DOM output of server components; don't attempt to test their internals.
- Mock server-side API calls via environment variables pointing to a test API; use route interception only for client-side fetches.
- Save authentication state after login and reuse it across tests to speed up your suite and reduce flakiness.
- Always verify your app builds cleanly before running tests; catch config errors early.
- Use
data-testidattributes to write tests that survive styling and markup changes.






Comments
Loading comments…