E2E Testing a Next.js App with Playwright: Auth, Roles, and Multi-Device Coverage
A practical guide to setting up Playwright in a real Next.js project — covering multi-project config, shared auth state, custom fixtures, and CI-ready patterns.
E2E Testing a Next.js App with Playwright: Auth, Roles, and Multi-Device Coverage
Unit tests tell you your functions work. E2E tests tell you your app works — from the browser's point of view. After adding Playwright to a production Next.js app with authentication, multiple user roles, and i18n routing, here's what I learned the hard way.
Why Playwright Over Cypress
Both are solid, but Playwright wins for Next.js projects because:
- Multi-browser out of the box — Chromium, Firefox, and WebKit with zero config
- First-class TypeScript — no plugin required
- Parallel projects — run different test suites (public, customer, admin) concurrently
storageState— persist authenticated sessions across tests without logging in every time
Project Structure
Here's how the test directory is organized:
tests/
e2e/
setup/
auth.setup.ts ← signs in and saves session state
public/
homepage.spec.ts ← unauthenticated pages
route-status.spec.ts
booking/
booking.spec.ts ← customer-only flows
dashboard/
dashboard.spec.ts
admin/
admin.spec.ts ← admin-only flows
interaction/
public-controls.spec.ts ← cross-device smoke tests
fixtures.ts ← shared helpers, re-exported test/expect
Separating by role makes it easy to run only the suite you care about and to assign dependencies correctly in the config.
The Config: Multi-Project, Multi-Role
The heart of Playwright in a complex app is playwright.config.ts. The key insight is modeling each user role as a separate project:
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000";
export default defineConfig({
testDir: "tests/e2e",
timeout: 60_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
// Runs first — saves auth cookies to disk
{
name: "auth-setup",
testDir: "tests/e2e/setup",
testMatch: /auth\.setup\.ts/,
},
// No auth required
{
name: "public",
use: { ...devices["Desktop Chrome"] },
testMatch: /public\/.+\.spec\.ts/,
},
// Requires customer session — depends on auth-setup
{
name: "customer",
use: {
...devices["Desktop Chrome"],
storageState: "test-results/.auth/customer.json",
},
dependencies: ["auth-setup"],
testMatch: /(booking|dashboard|profile)\/.+\.spec\.ts/,
},
// Requires admin session
{
name: "admin",
use: {
...devices["Desktop Chrome"],
storageState: "test-results/.auth/admin.json",
},
dependencies: ["auth-setup"],
testMatch: /admin\/.+\.spec\.ts/,
},
// Same tests, different devices
{
name: "interaction-public-tablet",
use: { ...devices["iPad (gen 7)"] },
testMatch: /interaction\/public-controls\.spec\.ts/,
},
{
name: "interaction-public-mobile",
use: { ...devices["iPhone 13"] },
testMatch: /interaction\/public-controls\.spec\.ts/,
},
],
});dependencies: ["auth-setup"] guarantees the setup runs first. testMatch with regex keeps each project focused on its own files.
Auth Setup: Sign In Once, Reuse Everywhere
The most painful part of E2E testing authenticated apps is logging in before every test. Playwright's storageState solves this — sign in once, save the browser storage (cookies + localStorage) to a JSON file, and every subsequent test loads that file instead of hitting the login page.
// tests/e2e/setup/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
import { signIn, TEST_USERS, routes } from "../fixtures";
const CUSTOMER_FILE = "test-results/.auth/customer.json";
const ADMIN_FILE = "test-results/.auth/admin.json";
setup("authenticate as customer", async ({ page }) => {
await signIn(page, TEST_USERS.customer.email, TEST_USERS.customer.password);
await expect(page).toHaveURL(new RegExp(routes.dashboard));
await page.context().storageState({ path: CUSTOMER_FILE });
});
setup("authenticate as admin", async ({ page }) => {
await signIn(page, TEST_USERS.admin.email, TEST_USERS.admin.password);
await expect(page).not.toHaveURL(new RegExp("/auth/signin"));
await page.context().storageState({ path: ADMIN_FILE });
});Add test-results/.auth/ to .gitignore — those files contain real session tokens.
Fixtures: Shared Helpers Without Repetition
Instead of importing from @playwright/test directly in every spec, create a fixtures.ts that re-exports everything plus your project-specific helpers:
// tests/e2e/fixtures.ts
import { test as base, expect, type Page } from "@playwright/test";
import {
ROUTES,
PROTECTED_ROUTES,
ADMIN_ROUTES,
} from "../../src/constants/routes";
const LOCALE = process.env.PLAYWRIGHT_LOCALE || "en";
export function localePath(route: string): string {
return `/${LOCALE}${route}`;
}
export const routes = {
home: localePath(ROUTES.HOME),
signIn: localePath(ROUTES.SIGN_IN),
book: localePath(ROUTES.BOOK_APPOINTMENT),
dashboard: localePath(PROTECTED_ROUTES.DASHBOARD),
admin: localePath(ADMIN_ROUTES.ADMIN_DASHBOARD),
// ... rest of routes
} as const;
export const TEST_USERS = {
customer: { email: "customer1@dev.local", password: "customerPass123!" },
admin: { email: "admin1@dev.local", password: "adminPass123!" },
} as const;
export async function signIn(page: Page, email: string, password: string) {
await page.goto(routes.signIn);
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
}
// Re-export so specs only need one import
export { expect };
export const test = base;Now every spec starts with:
import { test, expect, routes } from "../fixtures";One import, everything available.
Writing the Tests
With the setup in place, tests stay clean and focused:
// tests/e2e/public/homepage.spec.ts
import { test, expect, routes } from "../fixtures";
test.describe("Homepage", () => {
test("renders hero section with CTA", async ({ page }) => {
await page.goto(routes.home);
await expect(page.getByRole("heading", { level: 1 }).first()).toBeVisible();
await expect(page.getByRole("link", { name: /book now/i })).toBeVisible();
});
test("displays services section", async ({ page }) => {
await page.goto(routes.home);
const section = page.locator("section").filter({
has: page.getByRole("heading", { name: /services/i }),
});
await expect(section).toBeVisible();
});
});For authenticated flows, the storageState is already applied via the project config — the test doesn't need to know about auth at all:
// tests/e2e/dashboard/dashboard.spec.ts
import { test, expect, routes } from "../fixtures";
test("customer can view their appointments", async ({ page }) => {
await page.goto(routes.dashboard);
// Already signed in via storageState — no login step needed
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});CI Integration
In CI, set these environment variables and Playwright handles the rest:
# GitHub Actions snippet
- name: Run Playwright tests
env:
PLAYWRIGHT_BASE_URL: http://localhost:3000
CI: true
run: npx playwright testKey CI behaviors from the config:
retries: 2— flaky network tests get a second chanceworkers: 1— prevents port conflicts on shared CI runnersreporter: "html"— generates a browsable report you can upload as an artifact
What I'd Do Differently
Use page.getByRole everywhere. It's more resilient than CSS selectors and doubles as an accessibility check. If a button isn't findable by role, it might not be accessible either.
Keep auth state in .gitignore. Obvious in hindsight, but easy to forget when you're moving fast.
Run interaction tests on real device emulations. The mobile viewport often catches layout bugs that desktop tests miss entirely.
Playwright's setup cost is real, but once it's running, catching a broken auth flow before it reaches production is worth every minute.