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.
bashnpx create-next-app@latest next-playwright-demo --ts --eslint --app cd next-playwright-demo
Install Playwright.
bashnpm 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.
tsimport { 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.1is often more reliable thanlocalhostin CI.- Running
next devlocally is convenient, but in CI you should test the production build withnext build && next start. trace,screenshot, andvideomake 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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
tsexport 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:
tsximport { 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:
tsimport { 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:
- log in once during setup, or
- programmatically seed the authenticated browser state
- 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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
- test route handlers through the browser UI that triggers them
- 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:
tsimport { 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:
tsimport { 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,getByTextwhen text is stable. - Use
data-testidfor 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:
tsawait expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByTestId('project-count')).toHaveText('Projects: 2');
This is not:
tsawait 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:
yamlname: 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, notnpm 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:
tsexport 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:
tsexport async function getProjectsByOwner(email: string): Promise<Project[]> { await new Promise((r) => setTimeout(r, 25)); return projects; }
Now run the suite:
bashnpm run test:e2e
You should see failures like:
dashboard server component renders server-fetched project dataGET /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:
tsimport { 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:
tsimport { 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/invoicesand 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:
- open the HTML report
- inspect the trace
- compare network responses and redirects
- confirm whether the failure is in middleware, server rendering, or a browser-side fetch
Run this locally after a failure:
bashnpx 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:
bashnpm install npx playwright install --with-deps npm run test:e2e
Then for CI parity:
bashnpm run build npm run start npm run test:e2e
And when validating the regression exercise:
- change
getProjectsByOwnerto return all projects - run
npm run test:e2e - confirm dashboard, API, and projects-page tests fail
- restore the correct filter
- 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.
