Debugg.ai

Playwright Testing Next.js: Complete Setup Guide

Article
Development

DebuggAI Team

October 22, 202621 min read
Playwright Testing Next.js: Complete Setup Guide

Hands-on Playwright testing for Next.js: setup, auth fixtures, server components, route handlers, CI, and real regression checks.

If you searched for playwright testing next.js, you probably do not need another abstract overview. You need a working setup that fits how Next.js apps are actually built now: App Router, server components, route handlers, authentication, CI, and a way to prove your tests catch something real. That is what this guide covers.

We will build a practical Playwright setup for a Next.js app and focus on the parts that usually get hand-waved away:

  • project setup for a Next.js app using the App Router
  • an authenticated Playwright fixture you can reuse across tests
  • tests for server-rendered pages and server components
  • tests for route handlers
  • a CI workflow that runs reliably
  • an intentional regression, so you can verify the suite fails for the right reason

The examples use TypeScript, Playwright, and a Next.js app with a simple session cookie based auth flow. If your app uses NextAuth, Clerk, Auth0, or custom auth, the same structure still applies. The details of login change, but the test architecture does not.

What we are testing in a Next.js app

Before writing code, it helps to be precise about what Playwright is doing well in a modern Next.js codebase.

With the App Router, your UI may include:

  • server components, rendered on the server and streamed to the browser
  • client components, hydrated in the browser
  • route handlers under app/api/.../route.ts
  • redirects and middleware-based auth gates
  • data fetched server-side during rendering

Playwright is an end-to-end browser test runner. That means it is best for verifying user-visible behavior across these boundaries, not for unit testing every component in isolation.

For a Next.js app, the highest-value Playwright tests usually answer questions like:

  • Can an unauthenticated user get redirected to login?
  • Can an authenticated user load a dashboard that depends on server-rendered data?
  • Does a route handler return the right response when triggered through the app?
  • Does a mutation update the UI correctly after a form submit?
  • If a server component query changes accidentally, will we notice?

That is the angle we will take here.

The example app structure

We will assume a Next.js project that looks like this:

txt
. ├── app │ ├── api │ │ ├── login │ │ │ └── route.ts │ │ ├── profile │ │ │ └── route.ts │ │ └── projects │ │ └── route.ts │ ├── dashboard │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── projects │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── lib │ ├── auth.ts │ └── db.ts ├── middleware.ts ├── playwright │ ├── fixtures.ts │ └── global-setup.ts ├── tests │ ├── auth.spec.ts │ ├── dashboard.spec.ts │ ├── projects-api.spec.ts │ └── regression.spec.ts ├── playwright.config.ts ├── package.json └── tsconfig.json

We will keep the app simple enough to follow, but realistic enough to exercise the important edges.

Install Playwright in a Next.js project

Start with a standard Next.js app.

bash
npx create-next-app@latest next-playwright-demo --ts --eslint --app cd next-playwright-demo

Install Playwright.

bash
npm install -D @playwright/test npx playwright install --with-deps

Your package.json should include scripts for local dev, build, and test:

json
{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed" } }

Now create playwright.config.ts.

ts
import { defineConfig, devices } from '@playwright/test'; const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; const baseURL = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['html'], ['github']] : [['list'], ['html']], use: { baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }, webServer: { command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev', url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120 * 1000 }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ] });

A few practical notes:

  • 127.0.0.1 is often more reliable than localhost in CI.
  • Running next dev locally is convenient, but in CI you should test the production build with next build && next start.
  • trace, screenshot, and video make failure investigation much faster.

Build a minimal app to test

To make the examples concrete, we need a tiny app with:

  • a login page
  • middleware-protected routes
  • a server component based dashboard
  • a route handler that returns project data

Auth helper

Create lib/auth.ts:

ts
import { cookies } from 'next/headers'; export type SessionUser = { email: string; role: 'user' | 'admin'; }; const SESSION_COOKIE = 'session'; export async function getSessionUser(): Promise<SessionUser | null> { const cookieStore = await cookies(); const raw = cookieStore.get(SESSION_COOKIE)?.value; if (!raw) return null; try { return JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); } catch { return null; } } export function encodeSession(user: SessionUser) { return Buffer.from(JSON.stringify(user)).toString('base64url'); } export const sessionCookieName = SESSION_COOKIE;

This is not production-grade security, and that is fine for a tutorial. The point is to have a deterministic auth mechanism for tests.

Middleware auth gate

Create middleware.ts:

ts
import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { sessionCookieName } from './lib/auth'; export function middleware(request: NextRequest) { const isProtected = request.nextUrl.pathname.startsWith('/dashboard') || request.nextUrl.pathname.startsWith('/projects'); if (!isProtected) { return NextResponse.next(); } const hasSession = request.cookies.has(sessionCookieName); if (hasSession) { return NextResponse.next(); } const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('next', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } export const config = { matcher: ['/dashboard/:path*', '/projects/:path*'] };

Login route handler

Create app/api/login/route.ts:

ts
import { NextResponse } from 'next/server'; import { encodeSession, sessionCookieName } from '@/lib/auth'; export async function POST(request: Request) { const body = await request.json(); const email = body.email as string | undefined; if (!email) { return NextResponse.json({ error: 'email is required' }, { status: 400 }); } const response = NextResponse.json({ ok: true }); response.cookies.set(sessionCookieName, encodeSession({ email, role: 'user' }), { httpOnly: true, sameSite: 'lax', path: '/' }); return response; }

Login page

Create app/login/page.tsx:

tsx
'use client'; import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); const [email, setEmail] = useState('alice@example.com'); const [error, setError] = useState<string | null>(null); const next = searchParams.get('next') || '/dashboard'; async function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); const res = await fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) }); if (!res.ok) { setError('Login failed'); return; } router.push(next); router.refresh(); } return ( <main> <h1>Login</h1> <form onSubmit={onSubmit}> <label htmlFor="email">Email</label> <input id="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <button type="submit">Sign in</button> </form> {error ? <p role="alert">{error}</p> : null} </main> ); }

Fake data source for server components and API routes

Create lib/db.ts:

ts
export type Project = { id: string; name: string; ownerEmail: string; }; const projects: Project[] = [ { id: 'p1', name: 'Alpha', ownerEmail: 'alice@example.com' }, { id: 'p2', name: 'Beta', ownerEmail: 'alice@example.com' }, { id: 'p3', name: 'Gamma', ownerEmail: 'bob@example.com' } ]; export async function getProjectsByOwner(email: string): Promise<Project[]> { await new Promise((r) => setTimeout(r, 25)); return projects.filter((p) => p.ownerEmail === email); }

Server component dashboard

Create app/dashboard/page.tsx:

tsx
import { getSessionUser } from '@/lib/auth'; import { getProjectsByOwner } from '@/lib/db'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { const user = await getSessionUser(); if (!user) { redirect('/login'); } const projects = await getProjectsByOwner(user.email); return ( <main> <h1>Dashboard</h1> <p data-testid="welcome">Welcome {user.email}</p> <p data-testid="project-count">Projects: {projects.length}</p> <ul> {projects.map((project) => ( <li key={project.id}>{project.name}</li> ))} </ul> </main> ); }

This page is important because it is a server component. The data is fetched before the browser sees the rendered result. A browser test still verifies it properly because Playwright is checking the output a real user gets.

Route handler for project data

Create app/api/projects/route.ts:

ts
import { NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth'; import { getProjectsByOwner } from '@/lib/db'; export async function GET() { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } const projects = await getProjectsByOwner(user.email); return NextResponse.json({ projects }); }

Projects page that calls the route handler from the browser

Create app/projects/page.tsx:

tsx
'use client'; import { useEffect, useState } from 'react'; type Project = { id: string; name: string; }; export default function ProjectsPage() { const [projects, setProjects] = useState<Project[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { fetch('/api/projects') .then(async (res) => { if (!res.ok) throw new Error('request failed'); return res.json(); }) .then((data) => setProjects(data.projects)) .catch(() => setError('Could not load projects')) .finally(() => setLoading(false)); }, []); return ( <main> <h1>Projects</h1> {loading ? <p>Loading...</p> : null} {error ? <p role="alert">{error}</p> : null} <ul> {projects.map((project) => ( <li key={project.id}>{project.name}</li> ))} </ul> </main> ); }

Create an authenticated Playwright fixture

This is the piece that pays off fastest in a real codebase. Logging in through the UI in every test works, but it is slower and makes tests more repetitive.

A better pattern is:

  1. log in once during setup, or
  2. programmatically seed the authenticated browser state
  3. reuse that state in tests that need a signed-in user

For this app, we can log in by calling the route handler directly and capturing the cookie.

Create playwright/fixtures.ts:

ts
import { test as base, expect, request, type APIRequestContext } from '@playwright/test'; export type AuthFixtures = { authenticatedRequest: APIRequestContext; }; export const test = base.extend<AuthFixtures>({ authenticatedRequest: async ({ baseURL }, use) => { const apiContext = await request.newContext({ baseURL }); const loginResponse = await apiContext.post('/api/login', { data: { email: 'alice@example.com' } }); await expect(loginResponse).toBeOK(); await use(apiContext); await apiContext.dispose(); } }); export { expect } from '@playwright/test';

That helps for API-level calls, but browser page tests also need auth. The cleanest path for page tests is using storageState.

Create playwright/global-setup.ts:

ts
import { request, type FullConfig } from '@playwright/test'; import path from 'path'; import fs from 'fs/promises'; async function globalSetup(config: FullConfig) { const baseURL = config.projects[0].use.baseURL as string; const authFile = path.join(process.cwd(), 'playwright/.auth/user.json'); await fs.mkdir(path.dirname(authFile), { recursive: true }); const context = await request.newContext({ baseURL }); const res = await context.post('/api/login', { data: { email: 'alice@example.com' } }); if (!res.ok()) { throw new Error(`Login failed during global setup: ${res.status()} ${res.statusText()}`); } await context.storageState({ path: authFile }); await context.dispose(); } export default globalSetup;

Now update playwright.config.ts:

ts
import { defineConfig, devices } from '@playwright/test'; const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; const baseURL = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; export default defineConfig({ testDir: './tests', globalSetup: './playwright/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['html'], ['github']] : [['list'], ['html']], use: { baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }, webServer: { command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev', url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120 * 1000 }, projects: [ { name: 'setup', testMatch: /global\.setup\.noop\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, dependencies: ['setup'] } ] });

You can skip the setup project entirely and just use globalSetup, but some teams like explicit dependency wiring. If you do not want the dummy setup project, remove it and keep only globalSetup plus the storageState path.

For simplicity, here is the cleaner version you can actually keep:

ts
import { defineConfig, devices } from '@playwright/test'; const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; const baseURL = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; export default defineConfig({ testDir: './tests', globalSetup: './playwright/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['html'], ['github']] : [['list'], ['html']], use: { baseURL, storageState: 'playwright/.auth/user.json', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }, webServer: { command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev', url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120 * 1000 }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ] });

That gives every test an authenticated browser by default. If you want public and private tests in the same suite, create separate projects or override storageState per test.

Write the first Playwright tests for Next.js auth

Create tests/auth.spec.ts:

ts
import { test, expect } from '@playwright/test'; test.describe('authentication flow', () => { test('redirects unauthenticated users to login', async ({ browser }) => { const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const page = await context.newPage(); await page.goto('/dashboard'); await expect(page).toHaveURL(/\/login\?next=%2Fdashboard/); await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); await context.close(); }); test('loads dashboard for an authenticated user', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByTestId('welcome')).toHaveText('Welcome alice@example.com'); await expect(page.getByTestId('project-count')).toHaveText('Projects: 2'); }); });

This does two useful things:

  • proves middleware redirects correctly for anonymous users
  • proves the authenticated storage state works for protected pages

Testing server components in Next.js with Playwright

A common misconception is that Playwright only really tests client-side behavior. In a Next.js app, it is also very useful for server components because users only see the final rendered page, not the implementation split between server and client.

The right strategy is not to test "server component internals" directly. Instead, verify the output that depends on server-side rendering and data fetching.

Create tests/dashboard.spec.ts:

ts
import { test, expect } from '@playwright/test'; test.describe('dashboard server component', () => { test('renders server-fetched project data', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByText('Alpha')).toBeVisible(); await expect(page.getByText('Beta')).toBeVisible(); await expect(page.getByText('Gamma')).toHaveCount(0); }); test('returns a fully rendered page on first load', async ({ page }) => { const responsePromise = page.waitForResponse((response) => response.url().includes('/dashboard') && response.request().resourceType() === 'document' ); await page.goto('/dashboard'); const response = await responsePromise; expect(response.ok()).toBeTruthy(); await expect(page.getByTestId('project-count')).toHaveText('Projects: 2'); }); });

These tests are valuable because they validate:

  • auth context is available to the server component
  • the server-side data fetch returns the right records
  • the rendered HTML contains the expected result

If someone changes getProjectsByOwner, session parsing, or server-side redirect logic, these tests will catch it.

Testing route handlers in a Next.js app

You have two good options here:

  1. test route handlers through the browser UI that triggers them
  2. test route handlers directly using Playwright's request client

You usually want both, but for different reasons.

  • Browser tests verify integration and user-visible outcomes.
  • API request tests are faster and easier to localize when a backend contract breaks.

Browser-level route handler test

Create tests/projects-page.spec.ts:

ts
import { test, expect } from '@playwright/test'; test('projects page loads data from the route handler', async ({ page }) => { await page.goto('/projects'); await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); await expect(page.getByText('Alpha')).toBeVisible(); await expect(page.getByText('Beta')).toBeVisible(); await expect(page.getByRole('alert')).toHaveCount(0); });

Direct route handler test using the authenticated fixture

Create tests/projects-api.spec.ts:

ts
import { test, expect } from '../playwright/fixtures'; test.describe('GET /api/projects', () => { test('returns projects for the authenticated user', async ({ authenticatedRequest }) => { const response = await authenticatedRequest.get('/api/projects'); await expect(response).toBeOK(); const json = await response.json(); expect(json).toEqual({ projects: [ { id: 'p1', name: 'Alpha', ownerEmail: 'alice@example.com' }, { id: 'p2', name: 'Beta', ownerEmail: 'alice@example.com' } ] }); }); test('returns 401 without auth', async ({ request }) => { const response = await request.get('/api/projects'); expect(response.status()).toBe(401); await expect(response).not.toBeOK(); await expect(await response.json()).toEqual({ error: 'unauthorized' }); }); });

This is one of the nicest patterns in Playwright for Next.js apps. You stay inside one toolchain, but cover both browser behavior and route-level contracts.

Verify that your selectors will age well

For Next.js pages that mix server and client components, selectors become a maintenance issue quickly if you tie them to layout details.

A few practical rules:

  • Prefer accessible selectors first: getByRole, getByLabel, getByText when text is stable.
  • Use data-testid for values that are important but not naturally accessible.
  • Do not chain brittle CSS selectors through generated markup.
  • Avoid selecting by implementation details of server versus client components. The user does not care, and neither should the test.

For example, this is durable:

ts
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByTestId('project-count')).toHaveText('Projects: 2');

This is not:

ts
await expect(page.locator('main > div:nth-child(2) > span')).toHaveText('Projects: 2');

If selector drift is a persistent problem, this is one place where a tool like debugg.ai can help by generating and repairing browser flows from actual UI behavior, but the baseline discipline still matters.

Add a CI workflow for Playwright and Next.js

Now make sure the suite runs in CI the same way it does locally.

Create .github/workflows/playwright.yml:

yaml
name: Playwright on: pull_request: push: branches: [main] jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run Playwright tests env: CI: true run: npm run test:e2e - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ retention-days: 14 - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: test-results/ retention-days: 14

This setup works well for many Next.js projects. A few details matter:

  • Use npm ci, not npm install, for reproducible builds.
  • Install only the browser you actually run in CI if you want to keep runtime down.
  • Always upload reports and artifacts, even on failure.

If your Next.js app requires environment variables, set them explicitly in the workflow. Do not rely on CI defaults that differ from local dev.

For example:

yaml
- name: Run Playwright tests env: CI: true NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:3000 SESSION_SECRET: test-secret run: npm run test:e2e

How to verify the suite catches a real regression

This is the part most teams skip. They add Playwright, see green checks, and assume the suite is meaningful. The only honest way to know is to introduce a controlled bug and watch the right test fail.

For this app, a good regression is one that affects a server component and a route handler differently enough to validate multiple paths.

Let us break the project filtering logic.

Change lib/db.ts from this:

ts
export async function getProjectsByOwner(email: string): Promise<Project[]> { await new Promise((r) => setTimeout(r, 25)); return projects.filter((p) => p.ownerEmail === email); }

To this buggy version:

ts
export async function getProjectsByOwner(email: string): Promise<Project[]> { await new Promise((r) => setTimeout(r, 25)); return projects; }

Now run the suite:

bash
npm run test:e2e

You should see failures like:

  • dashboard server component renders server-fetched project data
  • GET /api/projects returns projects for the authenticated user

Why this regression is useful:

  • the dashboard test proves server-rendered user-specific data is wrong
  • the API test proves the backend contract is wrong
  • the projects page browser test may still pass if it only checks that Alpha and Beta exist, which is a good reminder to assert the absence of unauthorized data too

So tighten the browser test.

Update tests/projects-page.spec.ts:

ts
import { test, expect } from '@playwright/test'; test('projects page loads only the authenticated user data', async ({ page }) => { await page.goto('/projects'); await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); await expect(page.getByText('Alpha')).toBeVisible(); await expect(page.getByText('Beta')).toBeVisible(); await expect(page.getByText('Gamma')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); });

Now all three paths should catch the bug.

That is a much more convincing suite than one that merely proves pages load.

Add one explicit regression test pattern

You usually do not keep intentionally failing tests in main, but it is useful to encode the risk you just validated.

Create tests/regression.spec.ts:

ts
import { test, expect } from '@playwright/test'; test('dashboard never shows another user project', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByText('Gamma')).toHaveCount(0); });

This may look trivial, but it captures a real access-control regression in a way that product and engineering both understand.

When you write Playwright tests for a Next.js app, these focused assertions often carry more value than broad page snapshots.

Common mistakes in playwright testing next.js apps

When teams struggle with playwright testing next.js, it is usually one of these issues.

1. Testing only happy-path rendering

If your assertion is only "page has heading", you are not testing the application logic. Verify actual user-specific content, redirects, and error states.

2. Logging in through the UI in every test

It works, but it slows down the suite and creates more moving parts. Use storageState or API-backed fixtures for most authenticated tests. Keep one or two full login-flow tests separately.

3. Running only against next dev

Development mode and production mode do not behave identically. CI should test the production build.

4. Ignoring server-rendered data boundaries

A server component test should validate rendered output that comes from server-side data. Do not try to pretend it is just a client-side widget.

5. Missing direct API assertions

Browser tests are essential, but direct request tests make route handler failures much easier to pinpoint.

6. Not proving the suite can fail for the right reason

A green test suite is not evidence by itself. Introduce a deliberate regression at least once when building out the suite.

A practical test mix for a real Next.js codebase

If you are deciding how much to test, here is a balanced starting point.

For each important authenticated workflow:

  • 1 browser test for the full user journey
  • 1 browser test for auth or redirect behavior
  • 1 direct API test for the route handler or mutation behind it
  • 1 regression-focused assertion for sensitive data boundaries

For example, if you have a billing area:

  • redirect anonymous users away from /billing
  • render the billing page for an authenticated user
  • call /api/billing/invoices and verify the contract
  • assert another account's invoice never appears

That mix gives you coverage across middleware, server rendering, client rendering, and API behavior without exploding test count.

When to use Playwright component tests versus end-to-end tests

This guide focuses on end-to-end testing because that is usually what people mean by playwright testing next.js, and it is the best fit for verifying App Router behavior in context.

But if you have a very interactive client component with lots of local state, Playwright component testing can still be useful for isolated behavior. Just do not confuse that with coverage for auth, route handlers, server rendering, or middleware. Those still need app-level tests.

A good rule:

  • Use component tests for isolated client UI behavior.
  • Use end-to-end tests for user journeys and cross-boundary integration.

Debugging failing Playwright tests in Next.js

When a test fails, the fastest path is usually:

  1. open the HTML report
  2. inspect the trace
  3. compare network responses and redirects
  4. confirm whether the failure is in middleware, server rendering, or a browser-side fetch

Run this locally after a failure:

bash
npx playwright show-report

If a route handler is failing, the trace often reveals whether:

  • the request never fired
  • the response was a 401 or 500
  • the UI swallowed the error and rendered a generic message

This matters in Next.js because the failure boundary is not always obvious from the page alone.

If you need deeper workflow-level verification across changing UIs, generated traces, or replay against preview environments, debugg.ai can be useful alongside Playwright, especially when you want broader browser coverage without hand-authoring every test. But the foundation should still be a small, explicit suite like the one above.

Final checklist

If you want a solid baseline for playwright testing next.js, make sure your project has all of these:

  • Playwright configured with a webServer
  • tests running against a production build in CI
  • reusable authenticated state with storageState
  • at least one anonymous redirect test
  • at least one authenticated server component test
  • at least one direct route handler test
  • artifacts uploaded from CI
  • one intentional regression exercise, so you know the suite catches a real bug

That setup is enough to cover a surprising amount of risk in a Next.js app.

Complete example commands

To make this fully runnable, here is the usual flow:

bash
npm install npx playwright install --with-deps npm run test:e2e

Then for CI parity:

bash
npm run build npm run start npm run test:e2e

And when validating the regression exercise:

  1. change getProjectsByOwner to return all projects
  2. run npm run test:e2e
  3. confirm dashboard, API, and projects-page tests fail
  4. restore the correct filter
  5. rerun and confirm green

That last step is the difference between having tests and having evidence.

If you implement the structure in this guide, you will have a Playwright setup for Next.js that covers the real seams in the framework: middleware, auth, server components, route handlers, and CI. More importantly, you will know it catches an actual regression instead of just loading pages and hoping for the best.