How to Set Up End-to-End Testing With Playwright in a Next.js App

A login button that renders perfectly in a unit test can still fail silently in production when the session cookie is misconfigured. Unit and integration tests guard your functions and components — but they cannot simulate a real user clicking through your app. That gap is where Playwright lives, and if your Next.js project does not have it wired up, you are shipping blind.

This guide walks through a production-grade Playwright setup: installation, configuration, CI integration, authentication state reuse, and testing dynamic routes. By the end, your team will have a test suite that mirrors what your users actually experience.


Why Playwright Over Other E2E Tools

Playwright, maintained by Microsoft, runs tests against real Chromium, Firefox, and WebKit browsers in parallel. Unlike Cypress, it supports multiple browser contexts in a single test run, handles network interception cleanly, and ships with a built-in test runner that integrates naturally with modern CI pipelines. For Next.js apps — which often mix SSR, SSG, and client-side navigation — Playwright's ability to wait on network idle states and intercept fetch calls makes it particularly well-suited.


Installation and Initial Configuration

Start by adding Playwright to your project:

npm init playwright@latest
# or
npx playwright install --with-deps

This scaffolds a playwright.config.ts file and an e2e/ directory. Here is a baseline config tuned for a Next.js app running locally:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

The webServer block automatically starts your Next.js dev server before the test run and tears it down after — no manual npm run dev required. In CI, set reuseExistingServer to false so each run gets a clean server instance.


Writing Your First Flow Test

Place tests in the e2e/ directory. A good first target is the critical path: can a user land on the homepage, navigate to a product page, and complete a key action?

// e2e/homepage.spec.ts
import { test, expect } from '@playwright/test';

test('user can navigate from homepage to product detail', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/My App/);

  await page.getByRole('link', { name: 'View Products' }).click();
  await expect(page).toHaveURL(/\/products/);

  await page.getByTestId('product-card').first().click();
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

Use getByRole and getByTestId over CSS selectors. Role-based queries mirror how assistive technologies traverse the DOM, making tests more resilient to styling changes while improving your accessibility posture as a side effect.


Reusing Authentication State

Re-authenticating before every test is slow and fragile. Playwright's storageState feature lets you log in once, save the session to a file, and load it across tests.

Create a global setup file:

// e2e/global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('http://localhost:3000/login');
  await page.fill('[name="email"]', process.env.TEST_EMAIL!);
  await page.fill('[name="password"]', process.env.TEST_PASSWORD!);
  await page.click('[type="submit"]');
  await page.waitForURL('/dashboard');

  await page.context().storageState({ path: 'e2e/.auth/user.json' });
  await browser.close();
}

Register it in your config:

globalSetup: require.resolve('./e2e/global-setup'),

Then in any test that needs an authenticated user:

test.use({ storageState: 'e2e/.auth/user.json' });

This approach scales cleanly — add separate storage states for admin and regular user roles without duplicating login logic.


Testing Dynamic Routes

Next.js apps rely heavily on dynamic routes like /products/[slug] or /users/[id]/settings. Testing these requires either seeding known data or intercepting the API layer.

For teams with a staging database, seed a known record and hardcode its ID in tests. For teams that want isolation, mock the API response:

test('product detail page renders correctly', async ({ page }) => {
  await page.route('/api/products/test-slug', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: 'test-slug', name: 'Widget Pro', price: 49 }),
    });
  });

  await page.goto('/products/test-slug');
  await expect(page.getByRole('heading', { name: 'Widget Pro' })).toBeVisible();
});

Network interception is one of Playwright's strongest features — use it to test loading states, error boundaries, and edge cases that are impossible to reproduce reliably against a live backend.


CI Integration With GitHub Actions

A test suite that only runs locally provides limited value. Here is a minimal GitHub Actions workflow:

name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          BASE_URL: http://localhost:3000
          TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Uploading the HTML report as an artifact on failure means your team can inspect screenshots, traces, and step-by-step timelines for any broken test — directly from the GitHub Actions summary, without reproducing the failure locally.


Practical Tips for Sustainable Test Suites

  • Keep tests independent. Each test should set up its own state. Shared state between tests is the leading cause of flaky suites.
  • Avoid sleep. Replace page.waitForTimeout(2000) with page.waitForSelector or expect(locator).toBeVisible(). Playwright's auto-waiting handles most timing concerns.
  • Tag slow tests. Use test.slow() for tests that involve heavy SSR pages so Playwright triples the default timeout without affecting the rest of the suite.
  • Run on production builds in CI. Swap npm run dev for npm run build && npm run start in CI to catch build-time errors that only surface in production mode.

Why This Matters for Your Project

For SaaS products and customer-facing apps, a broken checkout, a failed OAuth callback, or a 404 on a dynamic route can cost real revenue and user trust before any monitoring alert fires. A well-structured Playwright suite, running on every pull request, transforms your deployment pipeline from an act of faith into a verified handoff. If your team is scaling a Next.js product — adding features, onboarding new engineers, or migrating infrastructure — end-to-end test coverage is the safety net that makes continuous delivery sustainable rather than stressful.