When to use: Testing Next.js applications -- App Router, Pages Router, API routes, middleware, SSR pages, dynamic routes, and server components. This guide covers E2E testing patterns specific to Next.js behavior. Prerequisites: core/configuration.md, core/locators.md
# Install Playwright in a Next.js project
npm init playwright@latest
# Run with Next.js dev server managed by Playwright
npx playwright test
# Run against a production build (recommended for CI)
npx playwright test --project=chromium
# Debug a single test with headed browser
npx playwright test tests/home.spec.ts --headed --debug# .env.test — loaded by Next.js automatically when NODE_ENV=test
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db
NEXTAUTH_SECRET=test-secret-do-not-use-in-production
NEXTAUTH_URL=http://localhost:3000
The single most important configuration detail: use webServer to let Playwright start and manage your Next.js server.
TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './tests',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npm run build && npm run start' // production build in CI
: 'npm run dev', // dev server locally
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? 'production' : 'test',
},
},
});JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
testMatch: '**/*.spec.js',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npm run build && npm run start'
: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? 'production' : 'test',
},
},
});Next.js loads .env.test automatically when NODE_ENV=test. Use this for test-specific overrides.
# .env.test (commit this -- no real secrets)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_FEATURE_FLAG_NEW_CHECKOUT=true
DATABASE_URL=postgresql://localhost:5432/test_db
# .env.test.local (gitignored -- real test secrets)
NEXTAUTH_SECRET=test-secret-local
STRIPE_TEST_KEY=sk_test_xxx# .gitignore
.env*.local
playwright-report/
playwright/.auth/
test-results/Use when: Testing pages built with the Next.js App Router (app/ directory). App Router pages are server components by default and may include streaming, suspense boundaries, and loading states.
Avoid when: You need to test isolated server component logic -- use unit tests for that. E2E tests verify the rendered result.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('App Router pages', () => {
test('home page renders server component content', async ({ page }) => {
await page.goto('/');
// Server components render on the server -- by the time Playwright
// sees the page, SSR content is already in the HTML
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
});
test('loading state shows while data streams in', async ({ page }) => {
// Slow down the API to expose the loading state
await page.route('**/api/dashboard/stats', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await page.goto('/dashboard');
// Verify the loading skeleton appears during streaming
await expect(page.getByRole('progressbar')).toBeVisible();
// Then verify the real content replaces it
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();
});
test('suspense boundary shows fallback then resolves', async ({ page }) => {
await page.goto('/products');
// The product list may be inside a Suspense boundary
// Playwright auto-waits, so just assert the final state
await expect(page.getByRole('listitem')).toHaveCount(12);
});
test('nested layouts persist across navigation', async ({ page }) => {
await page.goto('/dashboard/analytics');
// Verify the dashboard layout sidebar is visible
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
await expect(sidebar).toBeVisible();
// Navigate to a sibling route -- layout should persist (no full reload)
await sidebar.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('/dashboard/settings');
// Sidebar is still there -- layout was not re-mounted
await expect(sidebar).toBeVisible();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('App Router pages', () => {
test('home page renders server component content', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
});
test('loading state shows while data streams in', async ({ page }) => {
await page.route('**/api/dashboard/stats', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await page.goto('/dashboard');
await expect(page.getByRole('progressbar')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();
});
test('suspense boundary shows fallback then resolves', async ({ page }) => {
await page.goto('/products');
await expect(page.getByRole('listitem')).toHaveCount(12);
});
test('nested layouts persist across navigation', async ({ page }) => {
await page.goto('/dashboard/analytics');
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
await expect(sidebar).toBeVisible();
await sidebar.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('/dashboard/settings');
await expect(sidebar).toBeVisible();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
});Use when: Testing pages built with the Pages Router (pages/ directory) that use getServerSideProps or getStaticProps for data fetching.
Avoid when: Testing the data fetching functions directly -- that is a unit test concern. E2E tests verify what the user sees.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Pages Router with SSR', () => {
test('page with getServerSideProps renders fetched data', async ({ page }) => {
await page.goto('/blog');
// getServerSideProps fetches posts on the server -- verify they render
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
await expect(page.getByRole('article')).toHaveCount(10);
// Verify server-fetched data appears (not a loading skeleton)
await expect(page.getByRole('article').first()).toContainText(/\w+/);
});
test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
await page.goto('/about');
// Static pages are pre-rendered at build time -- content is immediate
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
await expect(page.getByText('Founded in 2020')).toBeVisible();
});
test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
await page.goto('/blog');
// Click a next/link -- this should be a client-side transition, not a full reload
const navigationPromise = page.waitForURL('/blog/my-first-post');
await page.getByRole('link', { name: 'My First Post' }).click();
await navigationPromise;
await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Pages Router with SSR', () => {
test('page with getServerSideProps renders fetched data', async ({ page }) => {
await page.goto('/blog');
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
await expect(page.getByRole('article')).toHaveCount(10);
await expect(page.getByRole('article').first()).toContainText(/\w+/);
});
test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
await page.goto('/about');
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
await expect(page.getByText('Founded in 2020')).toBeVisible();
});
test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
await page.goto('/blog');
const navigationPromise = page.waitForURL('/blog/my-first-post');
await page.getByRole('link', { name: 'My First Post' }).click();
await navigationPromise;
await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
});
});Use when: Testing pages with dynamic segments like /blog/[slug], /products/[id], or catch-all routes like /docs/[...path].
Avoid when: The route is static -- no dynamic segments involved.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('dynamic routes', () => {
test('dynamic [slug] page renders correct content', async ({ page }) => {
await page.goto('/blog/nextjs-testing-guide');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
// Verify the slug maps to the correct content, not a 404
await expect(page.getByText('Page not found')).toBeHidden();
});
test('non-existent slug shows 404 page', async ({ page }) => {
const response = await page.goto('/blog/this-post-does-not-exist');
// Next.js returns 404 for pages that call notFound() or return { notFound: true }
expect(response?.status()).toBe(404);
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});
test('catch-all route handles nested paths', async ({ page }) => {
await page.goto('/docs/getting-started/installation');
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
// Navigate to a different docs path
await page.goto('/docs/api/configuration');
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});
test('dynamic route with query parameters', async ({ page }) => {
await page.goto('/products?category=electronics&sort=price-asc');
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
// Verify sort order is applied
const prices = await page.getByTestId('product-price').allTextContents();
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('dynamic routes', () => {
test('dynamic [slug] page renders correct content', async ({ page }) => {
await page.goto('/blog/nextjs-testing-guide');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
await expect(page.getByText('Page not found')).toBeHidden();
});
test('non-existent slug shows 404 page', async ({ page }) => {
const response = await page.goto('/blog/this-post-does-not-exist');
expect(response?.status()).toBe(404);
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});
test('catch-all route handles nested paths', async ({ page }) => {
await page.goto('/docs/getting-started/installation');
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
await page.goto('/docs/api/configuration');
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});
test('dynamic route with query parameters', async ({ page }) => {
await page.goto('/products?category=electronics&sort=price-asc');
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
const prices = await page.getByTestId('product-price').allTextContents();
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
});
});Use when: Testing Next.js API routes (app/api/ or pages/api/) directly with Playwright's request context, or indirectly through UI interactions that call them.
Avoid when: Unit testing API handler logic in isolation -- use a unit testing framework for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('API routes -- direct testing', () => {
test('GET /api/products returns product list', async ({ request }) => {
const response = await request.get('/api/products');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.products).toBeInstanceOf(Array);
expect(body.products.length).toBeGreaterThan(0);
expect(body.products[0]).toHaveProperty('id');
expect(body.products[0]).toHaveProperty('name');
expect(body.products[0]).toHaveProperty('price');
});
test('POST /api/products creates a new product', async ({ request }) => {
const response = await request.post('/api/products', {
data: {
name: 'Test Product',
price: 29.99,
description: 'Created by Playwright',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.product.name).toBe('Test Product');
});
test('POST /api/products validates required fields', async ({ request }) => {
const response = await request.post('/api/products', {
data: { name: '' }, // missing required fields
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContainEqual(
expect.objectContaining({ field: 'price' })
);
});
});
test.describe('API routes -- indirect through UI', () => {
test('form submission calls API and shows result', async ({ page }) => {
await page.goto('/products/new');
await page.getByLabel('Product name').fill('Widget');
await page.getByLabel('Price').fill('19.99');
await page.getByRole('button', { name: 'Create product' }).click();
// The UI calls POST /api/products internally
await expect(page.getByText('Product created successfully')).toBeVisible();
await page.waitForURL('/products/**');
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('API routes -- direct testing', () => {
test('GET /api/products returns product list', async ({ request }) => {
const response = await request.get('/api/products');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.products).toBeInstanceOf(Array);
expect(body.products.length).toBeGreaterThan(0);
expect(body.products[0]).toHaveProperty('id');
expect(body.products[0]).toHaveProperty('name');
expect(body.products[0]).toHaveProperty('price');
});
test('POST /api/products creates a new product', async ({ request }) => {
const response = await request.post('/api/products', {
data: {
name: 'Test Product',
price: 29.99,
description: 'Created by Playwright',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.product.name).toBe('Test Product');
});
test('POST /api/products validates required fields', async ({ request }) => {
const response = await request.post('/api/products', {
data: { name: '' },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContainEqual(
expect.objectContaining({ field: 'price' })
);
});
});
test.describe('API routes -- indirect through UI', () => {
test('form submission calls API and shows result', async ({ page }) => {
await page.goto('/products/new');
await page.getByLabel('Product name').fill('Widget');
await page.getByLabel('Price').fill('19.99');
await page.getByRole('button', { name: 'Create product' }).click();
await expect(page.getByText('Product created successfully')).toBeVisible();
await page.waitForURL('/products/**');
});
});Use when: Testing Next.js middleware that handles redirects, rewrites, authentication guards, geolocation-based routing, or header manipulation.
Avoid when: The middleware logic is trivial -- a redirect from /old to /new can be verified with a simple navigation test.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('middleware', () => {
test('unauthenticated user is redirected to login', async ({ page }) => {
// Visit a protected page without auth cookies
const response = await page.goto('/dashboard');
// Middleware should redirect to /login
expect(page.url()).toContain('/login');
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('middleware redirect preserves the return URL', async ({ page }) => {
await page.goto('/dashboard/settings');
// Should redirect to login with a callbackUrl or returnTo parameter
const url = new URL(page.url());
expect(url.pathname).toBe('/login');
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
.toContain('/dashboard/settings');
});
test('middleware sets security headers', async ({ page }) => {
const response = await page.goto('/');
const headers = response!.headers();
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
});
test('middleware rewrites based on locale', async ({ page, context }) => {
// Set Accept-Language header to simulate a French user
await context.setExtraHTTPHeaders({
'Accept-Language': 'fr-FR,fr;q=0.9',
});
await page.goto('/');
// Middleware should rewrite to the French locale
await expect(page.getByText('Bienvenue')).toBeVisible();
});
test('middleware blocks unauthorized API access', async ({ request }) => {
// Call a protected API route without authentication
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('middleware', () => {
test('unauthenticated user is redirected to login', async ({ page }) => {
const response = await page.goto('/dashboard');
expect(page.url()).toContain('/login');
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('middleware redirect preserves the return URL', async ({ page }) => {
await page.goto('/dashboard/settings');
const url = new URL(page.url());
expect(url.pathname).toBe('/login');
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
.toContain('/dashboard/settings');
});
test('middleware sets security headers', async ({ page }) => {
const response = await page.goto('/');
const headers = response.headers();
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
});
test('middleware rewrites based on locale', async ({ page, context }) => {
await context.setExtraHTTPHeaders({
'Accept-Language': 'fr-FR,fr;q=0.9',
});
await page.goto('/');
await expect(page.getByText('Bienvenue')).toBeVisible();
});
test('middleware blocks unauthorized API access', async ({ request }) => {
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
});Use when: Verifying that server-rendered HTML matches the client-side hydrated output. Hydration mismatches cause visual flicker, broken interactivity, or React errors in the console. Avoid when: The page has no interactive client components -- pure server components do not hydrate.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('hydration', () => {
test('no hydration errors in console', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
// Wait for hydration to complete -- interactive elements become clickable
await page.getByRole('button', { name: 'Get started' }).click();
// Filter for hydration-specific errors
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes('Hydration') ||
e.includes('hydration') ||
e.includes('server-rendered') ||
e.includes('did not match')
);
expect(hydrationErrors).toEqual([]);
});
test('interactive elements work after hydration', async ({ page }) => {
await page.goto('/');
// This button relies on a client component event handler
// If hydration fails, the click will do nothing
const counter = page.getByTestId('counter-value');
await expect(counter).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(counter).toHaveText('1');
});
test('date/time renders without hydration mismatch', async ({ page }) => {
// Dates are a common source of hydration mismatch because server
// and client may be in different timezones
await page.goto('/dashboard');
// Verify the date displays without flicker
const dateElement = page.getByTestId('current-date');
await expect(dateElement).toBeVisible();
// Verify it contains a plausible date format, not "undefined" or garbled text
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('hydration', () => {
test('no hydration errors in console', async ({ page }) => {
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes('Hydration') ||
e.includes('hydration') ||
e.includes('server-rendered') ||
e.includes('did not match')
);
expect(hydrationErrors).toEqual([]);
});
test('interactive elements work after hydration', async ({ page }) => {
await page.goto('/');
const counter = page.getByTestId('counter-value');
await expect(counter).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(counter).toHaveText('1');
});
test('date/time renders without hydration mismatch', async ({ page }) => {
await page.goto('/dashboard');
const dateElement = page.getByTestId('current-date');
await expect(dateElement).toBeVisible();
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
});
});Use when: Verifying that next/image components render correctly, lazy load offscreen images, and serve optimized formats.
Avoid when: You do not use next/image or image optimization is not a concern for your test.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('next/image', () => {
test('hero image loads with correct attributes', async ({ page }) => {
await page.goto('/');
const heroImage = page.getByRole('img', { name: 'Hero banner' });
await expect(heroImage).toBeVisible();
// Verify next/image sets srcset for responsive loading
const srcset = await heroImage.getAttribute('srcset');
expect(srcset).toBeTruthy();
expect(srcset).toContain('w='); // next/image adds width descriptors
// Verify priority images are not lazy-loaded
const loading = await heroImage.getAttribute('loading');
expect(loading).not.toBe('lazy'); // priority images use eager loading
});
test('offscreen images lazy load on scroll', async ({ page }) => {
await page.goto('/gallery');
// Get an image that is below the fold
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
// Before scroll: image should not have loaded its src yet
const initialSrc = await offscreenImage.getAttribute('src');
// next/image uses a blur placeholder or empty src for lazy images
// Scroll the image into view
await offscreenImage.scrollIntoViewIfNeeded();
await expect(offscreenImage).toBeVisible();
// Verify the image has loaded (naturalWidth > 0 means the image loaded)
const naturalWidth = await offscreenImage.evaluate(
(img: HTMLImageElement) => img.naturalWidth
);
expect(naturalWidth).toBeGreaterThan(0);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('next/image', () => {
test('hero image loads with correct attributes', async ({ page }) => {
await page.goto('/');
const heroImage = page.getByRole('img', { name: 'Hero banner' });
await expect(heroImage).toBeVisible();
const srcset = await heroImage.getAttribute('srcset');
expect(srcset).toBeTruthy();
expect(srcset).toContain('w=');
const loading = await heroImage.getAttribute('loading');
expect(loading).not.toBe('lazy');
});
test('offscreen images lazy load on scroll', async ({ page }) => {
await page.goto('/gallery');
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
await offscreenImage.scrollIntoViewIfNeeded();
await expect(offscreenImage).toBeVisible();
const naturalWidth = await offscreenImage.evaluate(
(img) => img.naturalWidth
);
expect(naturalWidth).toBeGreaterThan(0);
});
});Use when: Testing login flows in Next.js apps using NextAuth.js or Auth.js. Use a setup project to authenticate once, then reuse storageState across tests.
Avoid when: Your app does not use session-based authentication.
TypeScript
// playwright.config.ts (auth-specific excerpt)
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'unauthenticated',
// No storageState -- tests run as logged-out user
testMatch: '**/*.unauth.spec.ts',
},
],
});// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate via credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for the redirect after successful login
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Save authentication state (cookies + localStorage)
await page.context().storageState({ path: authFile });
});// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
// This test runs with the authenticated storageState from the setup project
test('authenticated user sees dashboard', async ({ page }) => {
await page.goto('/dashboard');
// No login redirect -- auth cookies are already set
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('test@example.com')).toBeVisible();
});JavaScript
// tests/auth.setup.js
const { test: setup, expect } = require('@playwright/test');
const authFile = 'playwright/.auth/user.json';
setup('authenticate via credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});// tests/dashboard.spec.js
const { test, expect } = require('@playwright/test');
test('authenticated user sees dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('test@example.com')).toBeVisible();
});| Scenario | Command | Trade-off |
|---|---|---|
| Local development | npm run dev |
Hot reload, fast iteration, but does not test production behavior (minification, optimization, middleware edge runtime) |
| CI pipeline | npm run build && npm run start |
Tests the real production bundle; catches build errors, middleware edge cases |
| Quick smoke test | npm run dev in CI with reuseExistingServer: false |
Faster CI but misses production-only bugs |
Recommendation: Use npm run dev locally for fast feedback. Use npm run build && npm run start in CI to test the real production artifact.
Next.js server components run on the server and produce HTML. Playwright tests the rendered output. You cannot import and render a server component in a Playwright test. Instead:
- Test the final rendered HTML through navigation (
page.goto) - Verify that server-fetched data appears on the page
- Use API route tests to validate the data layer separately
Next.js redirects (configured in next.config.js, middleware, or redirect() in server actions) are transparent to Playwright. After page.goto(), check page.url() to verify the final destination.
If using Turbopack (next dev --turbopack), update your webServer.command:
webServer: {
command: process.env.CI
? 'npm run build && npm run start'
: 'npx next dev --turbopack',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},If your Next.js app consumes a separate backend API:
webServer: [
{
command: 'npm run dev:api',
url: 'http://localhost:4000/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
],| Don't Do This | Problem | Do This Instead |
|---|---|---|
await page.waitForTimeout(3000) after navigation |
Next.js client-side transitions are fast; arbitrary waits are wasteful and fragile | await page.waitForURL('/expected-path') or await expect(locator).toBeVisible() |
Test getServerSideProps by importing and calling it directly |
It depends on context (req/res) that Playwright cannot provide; it is a unit test concern |
Navigate to the page and verify the rendered output |
Mock your own API routes with page.route() |
You are testing a fiction; your API handler may have bugs the mock hides | Let the real API route handle requests; mock only external services |
Use page.goto('http://localhost:3000/path') with full URL |
Breaks when port or host changes; ignores baseURL |
Use page.goto('/path') and configure baseURL in config |
Run npm run build && npm run start locally for every test run |
Extremely slow feedback loop during development | Use npm run dev locally with reuseExistingServer: true; reserve production builds for CI |
Test next/image by checking exact URL paths |
next/image rewrites image URLs through /_next/image; paths change between dev and prod |
Assert on alt text, visibility, naturalWidth > 0, and srcset existence |
Skip .env.test and hardcode test values in config |
Values scatter across config and test files; hard to maintain | Use .env.test for shared test values; .env.test.local for secrets |
| Test server actions by calling them as functions | Server actions are bound to the Next.js runtime; calling them outside a request context fails | Trigger server actions through their UI (form submissions, button clicks) |
| Ignore console errors during SSR tests | Hydration mismatches and server errors appear in the console and indicate real bugs | Listen for page.on('console') errors and fail the test if hydration warnings appear |
- core/configuration.md -- base Playwright configuration patterns including
webServer - core/authentication.md -- authentication setup projects and
storageStatereuse - core/api-testing.md -- testing API routes directly with
requestcontext - core/network-mocking.md -- mocking external APIs that Next.js API routes call
- core/when-to-mock.md -- when to mock vs hit real services
- core/react.md -- React-specific patterns that apply to Next.js client components
- ci/ci-github-actions.md -- CI setup with
npm run buildcaching for Next.js