Playwright

Playwright is a browser automation framework, originally built by Microsoft, that drives real Chromium, Firefox, and WebKit engines. Unlike Jest or Vitest paired with React Testing Library, Playwright doesn't simulate a DOM in Node.js -- it launches an actual browser, points it at an actual running instance of your Next.js app, and interacts with the page the way a user would.

That distinction is what makes Playwright the right tool for the parts of a Next.js app that component tests structurally cannot reach: async Server Components, Server Actions invoked through a real form submission and network round trip, route handlers, middleware redirects, cookies and authentication flows, and the full client/server request lifecycle. If a behavior only exists once the app is actually running as a server, Playwright is how you verify it.

Installation

The Playwright CLI can scaffold a config and install browser binaries in one step:


npm init playwright@latest

This installs @playwright/test, downloads the browser binaries Playwright drives, and generates a starter playwright.config.ts along with an example test in a tests/ (or e2e/) directory.



Configuring webServer

The single most important Next.js-specific setting in the config is webServer. Because Playwright tests need a real running app to talk to, the webServer option tells Playwright how to start one automatically before the test suite runs, and to reuse it (or wait for it to be healthy) rather than starting a fresh instance for every test file.

A typical playwright.config.ts for a Next.js project. In CI, command is usually pointed at next build && next start rather than next dev, so tests run against an optimized production build that behaves the same way your deployed app will.

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],

  // Automatically starts the Next.js server before the test suite runs,
  // and waits until it responds successfully before tests begin.
  webServer: {
    command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
})

With reuseExistingServer set, running the suite locally against a dev server you already have open (next dev) skips starting a second one, which keeps the local feedback loop fast; in CI, where nothing is already running, Playwright starts the server itself and tears it down after the run.



A Realistic Navigation Test

Playwright tests use test() and an injected page fixture representing one browser tab. Locators (page.getByRole(), page.getByText(), ...) mirror the same accessibility-first querying philosophy as React Testing Library, and expect() assertions auto-retry until the page settles, which avoids the flakiness of manually waiting for network requests to resolve.

Navigating from a marketing homepage into a blog post rendered by an async Server Component, and asserting on content that only exists once the server has actually fetched and rendered it.

e2e,blog-navigation.spec.ts:
import { test, expect } from '@playwright/test'

test('visitor can open a post from the blog index', async ({ page }) => {
  await page.goto('/blog')

  await expect(
    page.getByRole('heading', { name: 'Blog', level: 1 })
  ).toBeVisible()

  // This link's text was rendered server-side by an async Server
  // Component that fetched the post list -- a Jest/RTL test could
  // never reach this, since there is no real server behind jsdom.
  await page.getByRole('link', { name: 'Understanding Server Components' }).click()

  await expect(page).toHaveURL(/\/blog\/understanding-server-components/)
  await expect(
    page.getByRole('heading', { name: 'Understanding Server Components' })
  ).toBeVisible()
})


Testing a Server Action Through the UI

Because Playwright drives a real browser against a real server, submitting a form wired to a Server Action exercises the entire path: the client-side form submission, the POST request Next.js generates for the action, the server executing it, and the resulting UI update -- without mocking any part of it.

A newsletter form backed by a real Server Action, tested end-to-end.

e2e,newsletter.spec.ts:
import { test, expect } from '@playwright/test'

test('subscribing shows a confirmation message', async ({ page }) => {
  await page.goto('/')

  await page.getByLabel('Email').fill('ada@example.com')
  await page.getByRole('button', { name: 'Subscribe' }).click()

  // The Server Action ran on the actual server and the page re-rendered
  // with the result -- this is real network activity, not a mock.
  await expect(page.getByRole('status')).toHaveText(
    'Thanks for subscribing, ada@example.com!'
  )
})


Running the Suite

Add a script to package.json and run it locally or in CI:


package.json:
{
  "scripts": {
    "test:e2e": "playwright test"
  }
}

Playwright's HTML reporter (enabled above via reporter: 'html') generates a browsable report after a run, including screenshots, traces, and videos for failed tests, which makes debugging a failure in CI considerably easier than reading a stack trace alone.



Where Playwright Fits

Playwright complements, rather than replaces, the faster component-level tests covered by Jest, Vitest, and React Testing Library. A practical rule of thumb: push logic and isolated component behavior down into fast unit/component tests, and reserve Playwright for the handful of critical, full-stack user journeys where you need confidence that the client, the server, and the network between them are all actually working together.