Type something to search...
End-to-End Testing a Next.js App with Playwright

End-to-End Testing a Next.js App with Playwright

Unit tests tell you that a component renders the right markup for a given set of props. They can't tell you that your login flow works, that a Server Action actually saves to the database, that proxy.ts redirects anonymous users, or that an async Server Component streams in the right data. For that, you need to run the real app in a real browser and click through it the way a user would. That's what end-to-end (E2E) tests do, and Playwright is the tool most Next.js teams reach for.

Playwright drives Chromium, Firefox, and WebKit with one API, waits for elements automatically, isolates each test in a fresh browser context, and ships with a trace viewer that makes failures easy to debug. The Next.js docs recommend E2E tests specifically for async Server Components, since unit test runners don't support them yet.

This post covers setting up Playwright in a Next.js 16 project, running tests against a production build, writing stable tests, handling authentication, mocking network requests (and the Server Component catch there), visual and accessibility checks, and running everything in GitHub Actions.

Installing Playwright

Run the initializer from your project root:

npm init playwright@latest

It asks a few questions: where to put tests (I'll use e2e), whether to add a GitHub Actions workflow, and whether to install browsers. It then creates playwright.config.ts, an example test, and adds @playwright/test as a dev dependency.

If you skipped the browser download, install them separately:

npx playwright install --with-deps

Add a couple of scripts to package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

Also add the generated output folders to .gitignore:

/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

Configuring Playwright for Next.js

Replace the generated config with one tuned for a Next.js app:

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

const PORT = Number(process.env.PORT ?? 3000);
const baseURL = `http://localhost:${PORT}`;

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? [["html", { open: "never" }], ["github"]] : "html",

  use: {
    baseURL,
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "mobile-safari",
      use: {
        ...devices["iPhone 15"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],

  webServer: {
    command: process.env.CI
      ? "npm run start"
      : "npm run build && npm run start",
    url: baseURL,
    reuseExistingServer: !process.env.CI,
    timeout: 180_000,
  },
});

The settings that matter most:

  • webServer starts your app before the tests and stops it afterward. Playwright waits until url responds before running anything. reuseExistingServer lets you keep a server running locally between test runs, which saves a lot of time.
  • Production build. The command runs next build and next start rather than next dev. The dev server compiles routes on first request, shows the error overlay, and behaves differently around caching and prerendering. Testing the production build catches issues that only appear there, like a missing Suspense boundary that fails the build. In CI, build in a separate step (shown later) so the server command only starts the app.
  • baseURL lets tests call page.goto("/about") instead of hard-coding the host.
  • trace: "on-first-retry" records a full trace (DOM snapshots, network, console, actions) whenever a test fails and is retried. You'll use this constantly in CI.
  • projects run the same tests in several browsers and devices. The setup project handles login once, explained below.

If you're iterating quickly locally and don't want to rebuild each time, you can temporarily point command at npm run dev. Just make sure CI always tests a production build.

Writing Your First Test

Assume a blog with a home page that links to posts:

// e2e/navigation.spec.ts
import { test, expect } from "@playwright/test";

test.use({ storageState: { cookies: [], origins: [] } });

test("navigates from the home page to a post", async ({ page }) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/TideWave/);

  await page.getByRole("link", { name: "Read the latest post" }).click();

  await expect(page).toHaveURL(/\/blog\/.+/);
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});

test("shows the custom 404 page for unknown routes", async ({ page }) => {
  const response = await page.goto("/this-page-does-not-exist");

  expect(response?.status()).toBe(404);
  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible();
});

test.use({ storageState: ... }) at the top of the file overrides the logged-in state from the config, so these tests run as an anonymous visitor.

Two things make Playwright tests reliable:

Locators are lazy and retrying. page.getByRole("link", { name: "..." }) doesn't look anything up until you act on it. When you call .click(), Playwright waits for the element to be attached, visible, stable, enabled, and not covered by another element, then clicks.

Web-first assertions retry. await expect(locator).toBeVisible() keeps checking until the condition is true or the timeout expires (5 seconds by default). You almost never need manual waits. If you find yourself writing page.waitForTimeout(1000), there's a better assertion to use instead.

Prefer User-Facing Locators

Playwright's recommended locators match how people perceive the page:

LocatorUse it for
getByRole("button", { name: "Save" })Buttons, links, headings, checkboxes, most interactive elements
getByLabel("Email")Form fields with a label
getByPlaceholder("Search...")Inputs without a visible label
getByText("Order confirmed")Non-interactive text
getByTestId("cart-total")Elements with no accessible name, as a last resort

CSS selectors like .btn-primary > span break when someone renames a class. Role-based locators break only when the user-visible behavior changes, which is exactly when you want a test to fail.

You don't have to write these by hand. npx playwright codegen http://localhost:3000 opens a browser and records your clicks as test code with good locators.

Testing Forms and Server Actions

E2E tests are where you verify that a form actually reaches the server and that the UI reflects the result:

// e2e/contact.spec.ts
import { test, expect } from "@playwright/test";

test.use({ storageState: { cookies: [], origins: [] } });

test.describe("contact form", () => {
  test("shows validation errors from the server", async ({ page }) => {
    await page.goto("/contact");

    await page.getByLabel("Email").fill("not-an-email");
    await page.getByRole("button", { name: "Send message" }).click();

    await expect(page.getByText("Enter a valid email address")).toBeVisible();
  });

  test("submits successfully", async ({ page }) => {
    await page.goto("/contact");

    await page.getByLabel("Name").fill("Maria");
    await page.getByLabel("Email").fill("maria@example.com");
    await page.getByLabel("Message").fill("Hello from Playwright");
    await page.getByRole("button", { name: "Send message" }).click();

    await expect(page.getByRole("status")).toHaveText(/thanks/i);
    await expect(page.getByLabel("Message")).toHaveValue("");
  });
});

These tests don't know or care that the form uses a Server Action. They'd keep passing if you swapped it for a Route Handler, which is the point: E2E tests check behavior, not implementation.

A Note on Hydration

Playwright can click a button as soon as it's visible in the server-rendered HTML, which may be before React has hydrated it. If the button relies on a client-side onClick, that early click does nothing and the test flakes. There are a few ways to handle this:

  • Forms that use a Server Function in their action prop work before hydration, so this problem mostly disappears for them.
  • Assert on something that only exists after hydration before interacting, for example a control that's rendered disabled on the server and enabled on the client.
  • Avoid the pattern where interactive controls look ready in the HTML but do nothing until JavaScript loads. That's a real user problem on slow connections too, not just a test problem.

Reusing Authentication State

Logging in through the UI in every test is slow. Playwright's recommended approach is a setup project that logs in once and saves the browser's storage state (cookies and local storage) to a file. Other projects load that file and start already authenticated.

// e2e/auth.setup.ts
import { test as setup, expect } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_USER_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 });
});

The config already wires this up: the setup project matches *.setup.ts, and every browser project depends on it and uses storageState: "playwright/.auth/user.json".

Now a protected-page test is short:

// e2e/dashboard.spec.ts
import { test, expect } from "@playwright/test";

test("shows the user's projects", async ({ page }) => {
  await page.goto("/dashboard");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await expect(page.getByRole("list", { name: "Projects" })).toBeVisible();
});

test.describe("anonymous visitor", () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test("is redirected to the login page", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page).toHaveURL(/\/login/);
  });
});

The second test is a good example of something only E2E can verify: that your proxy.ts or layout-level auth check redirects anonymous users. See managing authentication in a Next.js application for how those checks are built.

Use a dedicated test account, and never commit the playwright/.auth folder; it contains live session cookies.

Managing Test Data

E2E tests run against a real backend, so data is the hardest part. A few approaches, from simplest to most robust:

  • A seeded test database. Run a seed script before the suite so known records exist. Point the app at it with environment variables in your test run.
  • Create data per test. Use Playwright's request fixture to call an API (or a test-only Route Handler) that creates exactly what the test needs, so tests don't depend on each other.
  • Unique values. When tests create records through the UI, include something unique (like test.info().testId or a timestamp) so parallel tests don't collide.

Here's a test that creates its own data through the API before visiting the page:

// e2e/projects.spec.ts
import { test, expect } from "@playwright/test";

test("renames a project", async ({ page, request }) => {
  const name = `E2E project ${Date.now()}`;
  const res = await request.post("/api/projects", { data: { name } });
  expect(res.ok()).toBeTruthy();
  const project = await res.json();

  await page.goto(`/projects/${project.id}`);
  await page.getByRole("button", { name: "Rename" }).click();
  await page.getByLabel("Project name").fill(`${name} (renamed)`);
  await page.getByRole("button", { name: "Save" }).click();

  await expect(page.getByRole("heading", { level: 1 })).toHaveText(
    `${name} (renamed)`,
  );
});

The request fixture shares cookies with the browser context, so the API call is authenticated as the same test user.

Mocking Network Requests

page.route intercepts requests made by the browser and lets you fulfill them with fake data:

// e2e/weather-widget.spec.ts
import { test, expect } from "@playwright/test";

test("shows an error when the weather API fails", async ({ page }) => {
  await page.route("**/api/weather*", (route) =>
    route.fulfill({ status: 500, body: "Internal Server Error" }),
  );

  await page.goto("/");
  await expect(page.getByText("Weather unavailable")).toBeVisible();
});

This is great for testing error states and slow responses in Client Components.

Here's the Next.js-specific catch: page.route only sees requests the browser makes. Data fetched inside Server Components, Server Actions, or Route Handlers is requested by the Node.js server, not the browser, so Playwright can't intercept it. If a Server Component calls a third-party API, you have to control that dependency on the server side instead:

  • Point the app at a mock server through an environment variable (for example API_BASE_URL) when running E2E tests. Playwright's webServer option accepts an array, so it can start both your mock server and the Next.js app.
  • Use a test or staging instance of the third-party service.
  • Seed a test database instead of mocking database calls.

Visual Regression Tests

Playwright can compare screenshots against a stored baseline:

// e2e/visual.spec.ts
import { test, expect } from "@playwright/test";

test.use({ storageState: { cookies: [], origins: [] } });

test("home page looks right", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveScreenshot("home.png", {
    fullPage: true,
    mask: [page.getByTestId("current-date")],
  });
});

The first run creates the baseline; later runs fail if pixels differ beyond a threshold. Update baselines with npx playwright test --update-snapshots. Mask anything that changes between runs (dates, random content, ads). Fonts and anti-aliasing differ between operating systems, so generate baselines on the same OS your CI uses, typically Linux in Docker.

Accessibility Checks

The @axe-core/playwright package runs the axe accessibility engine against the current page:

npm install -D @axe-core/playwright
// e2e/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.use({ storageState: { cookies: [], origins: [] } });

for (const path of ["/", "/blog", "/contact"]) {
  test(`${path} has no detectable a11y violations`, async ({ page }) => {
    await page.goto(path);
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
  });
}

Automated checks catch only part of accessibility issues (missing labels, contrast, invalid ARIA), but they're cheap to run on every page.

Debugging Failing Tests

Playwright has excellent tooling for figuring out why something failed:

  • UI mode (npx playwright test --ui): a watch-mode interface with a timeline of every action, DOM snapshots, and network logs. This is the best way to work on tests locally.
  • Headed and debug mode (npx playwright test --headed or --debug): watch the browser, step through actions, and inspect locators.
  • Trace viewer (npx playwright show-trace path/to/trace.zip): open a trace from CI and replay the failure step by step.
  • HTML report (npx playwright show-report): browse all results, with traces and screenshots attached to failures.

Running in GitHub Actions

Here's a workflow that builds the app once and runs the suite:

# .github/workflows/e2e.yml
name: E2E tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
      E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Build Next.js
        run: npm run build

      - name: Run Playwright tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Because CI is set automatically in GitHub Actions, the config's webServer.command only runs npm run start, using the build from the previous step. The report is uploaded even when tests fail, so you can download it and open traces. For large suites, Playwright's --shard option splits tests across multiple jobs.

What to Cover with E2E Tests

E2E tests are slower and more expensive to maintain than unit tests, so spend them on what matters most:

  • Critical user journeys: sign up, log in, checkout, publish.
  • Authorization: protected pages redirect, users can't see each other's data.
  • Async Server Components and streaming pages that unit tests can't render.
  • Integration points: forms that hit Server Actions, uploads, payment redirects.
  • Smoke tests that every important route returns 200 and renders a heading.

Leave detailed component logic (every validation message, every edge case of a date picker) to unit tests, as covered in the Jest and React Testing Library guide.

Conclusion

Playwright fills the gap that unit tests leave in a Next.js app: it runs your production build in real browsers and verifies that routing, Server Components, Server Actions, and auth all work together. Configure webServer to build and start the app, write locators based on roles and labels, rely on auto-waiting assertions instead of sleeps, log in once with a setup project, and remember that page.route only intercepts browser requests.

Start with a handful of tests for your most important flows, run them on every pull request, and use traces to debug failures. A small, reliable E2E suite is worth far more than a large, flaky one.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading