# Testing Next.js Apps with Playwright

> **When to use**: Testing Next.js applications -- App Router, Pages Router, API routes, middleware, SSR pages, dynamic routes, and server components. This guide covers E2E testing patterns specific to Next.js behavior.
> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)

## Quick Reference

```bash
# Install Playwright in a Next.js project
npm init playwright@latest

# Run with Next.js dev server managed by Playwright
npx playwright test

# Run against a production build (recommended for CI)
npx playwright test --project=chromium

# Debug a single test with headed browser
npx playwright test tests/home.spec.ts --headed --debug
```

```
# .env.test — loaded by Next.js automatically when NODE_ENV=test
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db
NEXTAUTH_SECRET=test-secret-do-not-use-in-production
NEXTAUTH_URL=http://localhost:3000
```

## Setup

### Playwright Config for Next.js

The single most important configuration detail: use `webServer` to let Playwright start and manage your Next.js server.

**TypeScript**

```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
import path from "path"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? "50%" : undefined,

  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "mobile",
      use: { ...devices["iPhone 14"] },
    },
  ],

  webServer: {
    command: process.env.CI
      ? "npm run build && npm run start" // production build in CI
      : "npm run dev", // dev server locally
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NODE_ENV: process.env.CI ? "production" : "test",
    },
  },
})
```

**JavaScript**

```javascript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? "50%" : undefined,

  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "mobile",
      use: { ...devices["iPhone 14"] },
    },
  ],

  webServer: {
    command: process.env.CI ? "npm run build && npm run start" : "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NODE_ENV: process.env.CI ? "production" : "test",
    },
  },
})
```

### Environment Variables with `.env.test`

Next.js loads `.env.test` automatically when `NODE_ENV=test`. Use this for test-specific overrides.

```bash
# .env.test (commit this -- no real secrets)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_FEATURE_FLAG_NEW_CHECKOUT=true
DATABASE_URL=postgresql://localhost:5432/test_db

# .env.test.local (gitignored -- real test secrets)
NEXTAUTH_SECRET=test-secret-local
STRIPE_TEST_KEY=sk_test_xxx
```

```bash
# .gitignore
.env*.local
playwright-report/
playwright/.auth/
test-results/
```

## Patterns

### Testing App Router Pages

**Use when**: Testing pages built with the Next.js App Router (`app/` directory). App Router pages are server components by default and may include streaming, suspense boundaries, and loading states.
**Avoid when**: You need to test isolated server component logic -- use unit tests for that. E2E tests verify the rendered result.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("App Router pages", () => {
  test("home page renders server component content", async ({ page }) => {
    await page.goto("/")

    // Server components render on the server -- by the time Playwright
    // sees the page, SSR content is already in the HTML
    await expect(page.getByRole("heading", { name: "Welcome", level: 1 })).toBeVisible()
    await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
  })

  test("loading state shows while data streams in", async ({ page }) => {
    // Slow down the API to expose the loading state
    await page.route("**/api/dashboard/stats", async (route) => {
      await new Promise((resolve) => setTimeout(resolve, 2000))
      await route.continue()
    })

    await page.goto("/dashboard")

    // Verify the loading skeleton appears during streaming
    await expect(page.getByRole("progressbar")).toBeVisible()

    // Then verify the real content replaces it
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
    await expect(page.getByRole("progressbar")).toBeHidden()
  })

  test("suspense boundary shows fallback then resolves", async ({ page }) => {
    await page.goto("/products")

    // The product list may be inside a Suspense boundary
    // Playwright auto-waits, so just assert the final state
    await expect(page.getByRole("listitem")).toHaveCount(12)
  })

  test("nested layouts persist across navigation", async ({ page }) => {
    await page.goto("/dashboard/analytics")

    // Verify the dashboard layout sidebar is visible
    const sidebar = page.getByRole("navigation", { name: "Dashboard" })
    await expect(sidebar).toBeVisible()

    // Navigate to a sibling route -- layout should persist (no full reload)
    await sidebar.getByRole("link", { name: "Settings" }).click()
    await page.waitForURL("/dashboard/settings")

    // Sidebar is still there -- layout was not re-mounted
    await expect(sidebar).toBeVisible()
    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("App Router pages", () => {
  test("home page renders server component content", async ({ page }) => {
    await page.goto("/")

    await expect(page.getByRole("heading", { name: "Welcome", level: 1 })).toBeVisible()
    await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
  })

  test("loading state shows while data streams in", async ({ page }) => {
    await page.route("**/api/dashboard/stats", async (route) => {
      await new Promise((resolve) => setTimeout(resolve, 2000))
      await route.continue()
    })

    await page.goto("/dashboard")

    await expect(page.getByRole("progressbar")).toBeVisible()
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
    await expect(page.getByRole("progressbar")).toBeHidden()
  })

  test("suspense boundary shows fallback then resolves", async ({ page }) => {
    await page.goto("/products")

    await expect(page.getByRole("listitem")).toHaveCount(12)
  })

  test("nested layouts persist across navigation", async ({ page }) => {
    await page.goto("/dashboard/analytics")

    const sidebar = page.getByRole("navigation", { name: "Dashboard" })
    await expect(sidebar).toBeVisible()

    await sidebar.getByRole("link", { name: "Settings" }).click()
    await page.waitForURL("/dashboard/settings")

    await expect(sidebar).toBeVisible()
    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
  })
})
```

### Testing Pages Router (getServerSideProps / getStaticProps)

**Use when**: Testing pages built with the Pages Router (`pages/` directory) that use `getServerSideProps` or `getStaticProps` for data fetching.
**Avoid when**: Testing the data fetching functions directly -- that is a unit test concern. E2E tests verify what the user sees.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("Pages Router with SSR", () => {
  test("page with getServerSideProps renders fetched data", async ({ page }) => {
    await page.goto("/blog")

    // getServerSideProps fetches posts on the server -- verify they render
    await expect(page.getByRole("heading", { name: "Blog", level: 1 })).toBeVisible()
    await expect(page.getByRole("article")).toHaveCount(10)

    // Verify server-fetched data appears (not a loading skeleton)
    await expect(page.getByRole("article").first()).toContainText(/\w+/)
  })

  test("page with getStaticProps shows pre-rendered content", async ({ page }) => {
    await page.goto("/about")

    // Static pages are pre-rendered at build time -- content is immediate
    await expect(page.getByRole("heading", { name: "About Us" })).toBeVisible()
    await expect(page.getByText("Founded in 2020")).toBeVisible()
  })

  test("client-side navigation with next/link preserves SPA behavior", async ({ page }) => {
    await page.goto("/blog")

    // Click a next/link -- this should be a client-side transition, not a full reload
    const navigationPromise = page.waitForURL("/blog/my-first-post")
    await page.getByRole("link", { name: "My First Post" }).click()
    await navigationPromise

    await expect(page.getByRole("heading", { name: "My First Post", level: 1 })).toBeVisible()
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("Pages Router with SSR", () => {
  test("page with getServerSideProps renders fetched data", async ({ page }) => {
    await page.goto("/blog")

    await expect(page.getByRole("heading", { name: "Blog", level: 1 })).toBeVisible()
    await expect(page.getByRole("article")).toHaveCount(10)
    await expect(page.getByRole("article").first()).toContainText(/\w+/)
  })

  test("page with getStaticProps shows pre-rendered content", async ({ page }) => {
    await page.goto("/about")

    await expect(page.getByRole("heading", { name: "About Us" })).toBeVisible()
    await expect(page.getByText("Founded in 2020")).toBeVisible()
  })

  test("client-side navigation with next/link preserves SPA behavior", async ({ page }) => {
    await page.goto("/blog")

    const navigationPromise = page.waitForURL("/blog/my-first-post")
    await page.getByRole("link", { name: "My First Post" }).click()
    await navigationPromise

    await expect(page.getByRole("heading", { name: "My First Post", level: 1 })).toBeVisible()
  })
})
```

### Testing Dynamic Routes (`[slug]`, `[...catchAll]`)

**Use when**: Testing pages with dynamic segments like `/blog/[slug]`, `/products/[id]`, or catch-all routes like `/docs/[...path]`.
**Avoid when**: The route is static -- no dynamic segments involved.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("dynamic routes", () => {
  test("dynamic [slug] page renders correct content", async ({ page }) => {
    await page.goto("/blog/nextjs-testing-guide")

    await expect(page.getByRole("heading", { level: 1 })).toContainText("Next.js Testing Guide")
    // Verify the slug maps to the correct content, not a 404
    await expect(page.getByText("Page not found")).toBeHidden()
  })

  test("non-existent slug shows 404 page", async ({ page }) => {
    const response = await page.goto("/blog/this-post-does-not-exist")

    // Next.js returns 404 for pages that call notFound() or return { notFound: true }
    expect(response?.status()).toBe(404)
    await expect(page.getByRole("heading", { name: "404" })).toBeVisible()
  })

  test("catch-all route handles nested paths", async ({ page }) => {
    await page.goto("/docs/getting-started/installation")

    await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible()

    // Navigate to a different docs path
    await page.goto("/docs/api/configuration")
    await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible()
  })

  test("dynamic route with query parameters", async ({ page }) => {
    await page.goto("/products?category=electronics&sort=price-asc")

    await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
    // Verify sort order is applied
    const prices = await page.getByTestId("product-price").allTextContents()
    const numericPrices = prices.map((p) => parseFloat(p.replace("$", "")))
    expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("dynamic routes", () => {
  test("dynamic [slug] page renders correct content", async ({ page }) => {
    await page.goto("/blog/nextjs-testing-guide")

    await expect(page.getByRole("heading", { level: 1 })).toContainText("Next.js Testing Guide")
    await expect(page.getByText("Page not found")).toBeHidden()
  })

  test("non-existent slug shows 404 page", async ({ page }) => {
    const response = await page.goto("/blog/this-post-does-not-exist")

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

  test("catch-all route handles nested paths", async ({ page }) => {
    await page.goto("/docs/getting-started/installation")

    await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible()

    await page.goto("/docs/api/configuration")
    await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible()
  })

  test("dynamic route with query parameters", async ({ page }) => {
    await page.goto("/products?category=electronics&sort=price-asc")

    await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
    const prices = await page.getByTestId("product-price").allTextContents()
    const numericPrices = prices.map((p) => parseFloat(p.replace("$", "")))
    expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))
  })
})
```

### Testing API Routes

**Use when**: Testing Next.js API routes (`app/api/` or `pages/api/`) directly with Playwright's `request` context, or indirectly through UI interactions that call them.
**Avoid when**: Unit testing API handler logic in isolation -- use a unit testing framework for that.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("API routes -- direct testing", () => {
  test("GET /api/products returns product list", async ({ request }) => {
    const response = await request.get("/api/products")

    expect(response.ok()).toBeTruthy()
    const body = await response.json()
    expect(body.products).toBeInstanceOf(Array)
    expect(body.products.length).toBeGreaterThan(0)
    expect(body.products[0]).toHaveProperty("id")
    expect(body.products[0]).toHaveProperty("name")
    expect(body.products[0]).toHaveProperty("price")
  })

  test("POST /api/products creates a new product", async ({ request }) => {
    const response = await request.post("/api/products", {
      data: {
        name: "Test Product",
        price: 29.99,
        description: "Created by Playwright",
      },
    })

    expect(response.status()).toBe(201)
    const body = await response.json()
    expect(body.product.name).toBe("Test Product")
  })

  test("POST /api/products validates required fields", async ({ request }) => {
    const response = await request.post("/api/products", {
      data: { name: "" }, // missing required fields
    })

    expect(response.status()).toBe(400)
    const body = await response.json()
    expect(body.error).toContainEqual(expect.objectContaining({ field: "price" }))
  })
})

test.describe("API routes -- indirect through UI", () => {
  test("form submission calls API and shows result", async ({ page }) => {
    await page.goto("/products/new")

    await page.getByLabel("Product name").fill("Widget")
    await page.getByLabel("Price").fill("19.99")
    await page.getByRole("button", { name: "Create product" }).click()

    // The UI calls POST /api/products internally
    await expect(page.getByText("Product created successfully")).toBeVisible()
    await page.waitForURL("/products/**")
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("API routes -- direct testing", () => {
  test("GET /api/products returns product list", async ({ request }) => {
    const response = await request.get("/api/products")

    expect(response.ok()).toBeTruthy()
    const body = await response.json()
    expect(body.products).toBeInstanceOf(Array)
    expect(body.products.length).toBeGreaterThan(0)
    expect(body.products[0]).toHaveProperty("id")
    expect(body.products[0]).toHaveProperty("name")
    expect(body.products[0]).toHaveProperty("price")
  })

  test("POST /api/products creates a new product", async ({ request }) => {
    const response = await request.post("/api/products", {
      data: {
        name: "Test Product",
        price: 29.99,
        description: "Created by Playwright",
      },
    })

    expect(response.status()).toBe(201)
    const body = await response.json()
    expect(body.product.name).toBe("Test Product")
  })

  test("POST /api/products validates required fields", async ({ request }) => {
    const response = await request.post("/api/products", {
      data: { name: "" },
    })

    expect(response.status()).toBe(400)
    const body = await response.json()
    expect(body.error).toContainEqual(expect.objectContaining({ field: "price" }))
  })
})

test.describe("API routes -- indirect through UI", () => {
  test("form submission calls API and shows result", async ({ page }) => {
    await page.goto("/products/new")

    await page.getByLabel("Product name").fill("Widget")
    await page.getByLabel("Price").fill("19.99")
    await page.getByRole("button", { name: "Create product" }).click()

    await expect(page.getByText("Product created successfully")).toBeVisible()
    await page.waitForURL("/products/**")
  })
})
```

### Testing Middleware

**Use when**: Testing Next.js middleware that handles redirects, rewrites, authentication guards, geolocation-based routing, or header manipulation.
**Avoid when**: The middleware logic is trivial -- a redirect from `/old` to `/new` can be verified with a simple navigation test.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("middleware", () => {
  test("unauthenticated user is redirected to login", async ({ page }) => {
    // Visit a protected page without auth cookies
    const response = await page.goto("/dashboard")

    // Middleware should redirect to /login
    expect(page.url()).toContain("/login")
    await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
  })

  test("middleware redirect preserves the return URL", async ({ page }) => {
    await page.goto("/dashboard/settings")

    // Should redirect to login with a callbackUrl or returnTo parameter
    const url = new URL(page.url())
    expect(url.pathname).toBe("/login")
    expect(url.searchParams.get("callbackUrl") || url.searchParams.get("returnTo")).toContain(
      "/dashboard/settings"
    )
  })

  test("middleware sets security headers", async ({ page }) => {
    const response = await page.goto("/")

    const headers = response!.headers()
    expect(headers["x-frame-options"]).toBe("DENY")
    expect(headers["x-content-type-options"]).toBe("nosniff")
    expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin")
  })

  test("middleware rewrites based on locale", async ({ page, context }) => {
    // Set Accept-Language header to simulate a French user
    await context.setExtraHTTPHeaders({
      "Accept-Language": "fr-FR,fr;q=0.9",
    })

    await page.goto("/")

    // Middleware should rewrite to the French locale
    await expect(page.getByText("Bienvenue")).toBeVisible()
  })

  test("middleware blocks unauthorized API access", async ({ request }) => {
    // Call a protected API route without authentication
    const response = await request.get("/api/admin/users")

    expect(response.status()).toBe(401)
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("middleware", () => {
  test("unauthenticated user is redirected to login", async ({ page }) => {
    const response = await page.goto("/dashboard")

    expect(page.url()).toContain("/login")
    await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
  })

  test("middleware redirect preserves the return URL", async ({ page }) => {
    await page.goto("/dashboard/settings")

    const url = new URL(page.url())
    expect(url.pathname).toBe("/login")
    expect(url.searchParams.get("callbackUrl") || url.searchParams.get("returnTo")).toContain(
      "/dashboard/settings"
    )
  })

  test("middleware sets security headers", async ({ page }) => {
    const response = await page.goto("/")

    const headers = response.headers()
    expect(headers["x-frame-options"]).toBe("DENY")
    expect(headers["x-content-type-options"]).toBe("nosniff")
    expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin")
  })

  test("middleware rewrites based on locale", async ({ page, context }) => {
    await context.setExtraHTTPHeaders({
      "Accept-Language": "fr-FR,fr;q=0.9",
    })

    await page.goto("/")

    await expect(page.getByText("Bienvenue")).toBeVisible()
  })

  test("middleware blocks unauthorized API access", async ({ request }) => {
    const response = await request.get("/api/admin/users")

    expect(response.status()).toBe(401)
  })
})
```

### Testing Hydration and SSR/CSR Consistency

**Use when**: Verifying that server-rendered HTML matches the client-side hydrated output. Hydration mismatches cause visual flicker, broken interactivity, or React errors in the console.
**Avoid when**: The page has no interactive client components -- pure server components do not hydrate.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("hydration", () => {
  test("no hydration errors in console", async ({ page }) => {
    const consoleErrors: string[] = []
    page.on("console", (msg) => {
      if (msg.type() === "error") {
        consoleErrors.push(msg.text())
      }
    })

    await page.goto("/")
    // Wait for hydration to complete -- interactive elements become clickable
    await page.getByRole("button", { name: "Get started" }).click()

    // Filter for hydration-specific errors
    const hydrationErrors = consoleErrors.filter(
      (e) =>
        e.includes("Hydration") ||
        e.includes("hydration") ||
        e.includes("server-rendered") ||
        e.includes("did not match")
    )
    expect(hydrationErrors).toEqual([])
  })

  test("interactive elements work after hydration", async ({ page }) => {
    await page.goto("/")

    // This button relies on a client component event handler
    // If hydration fails, the click will do nothing
    const counter = page.getByTestId("counter-value")
    await expect(counter).toHaveText("0")

    await page.getByRole("button", { name: "Increment" }).click()
    await expect(counter).toHaveText("1")
  })

  test("date/time renders without hydration mismatch", async ({ page }) => {
    // Dates are a common source of hydration mismatch because server
    // and client may be in different timezones
    await page.goto("/dashboard")

    // Verify the date displays without flicker
    const dateElement = page.getByTestId("current-date")
    await expect(dateElement).toBeVisible()
    // Verify it contains a plausible date format, not "undefined" or garbled text
    await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/)
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("hydration", () => {
  test("no hydration errors in console", async ({ page }) => {
    const consoleErrors = []
    page.on("console", (msg) => {
      if (msg.type() === "error") {
        consoleErrors.push(msg.text())
      }
    })

    await page.goto("/")
    await page.getByRole("button", { name: "Get started" }).click()

    const hydrationErrors = consoleErrors.filter(
      (e) =>
        e.includes("Hydration") ||
        e.includes("hydration") ||
        e.includes("server-rendered") ||
        e.includes("did not match")
    )
    expect(hydrationErrors).toEqual([])
  })

  test("interactive elements work after hydration", async ({ page }) => {
    await page.goto("/")

    const counter = page.getByTestId("counter-value")
    await expect(counter).toHaveText("0")

    await page.getByRole("button", { name: "Increment" }).click()
    await expect(counter).toHaveText("1")
  })

  test("date/time renders without hydration mismatch", async ({ page }) => {
    await page.goto("/dashboard")

    const dateElement = page.getByTestId("current-date")
    await expect(dateElement).toBeVisible()
    await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/)
  })
})
```

### Testing next/image Optimization

**Use when**: Verifying that `next/image` components render correctly, lazy load offscreen images, and serve optimized formats.
**Avoid when**: You do not use `next/image` or image optimization is not a concern for your test.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test.describe("next/image", () => {
  test("hero image loads with correct attributes", async ({ page }) => {
    await page.goto("/")

    const heroImage = page.getByRole("img", { name: "Hero banner" })
    await expect(heroImage).toBeVisible()

    // Verify next/image sets srcset for responsive loading
    const srcset = await heroImage.getAttribute("srcset")
    expect(srcset).toBeTruthy()
    expect(srcset).toContain("w=") // next/image adds width descriptors

    // Verify priority images are not lazy-loaded
    const loading = await heroImage.getAttribute("loading")
    expect(loading).not.toBe("lazy") // priority images use eager loading
  })

  test("offscreen images lazy load on scroll", async ({ page }) => {
    await page.goto("/gallery")

    // Get an image that is below the fold
    const offscreenImage = page.getByRole("img", { name: "Gallery item 20" })

    // Before scroll: image should not have loaded its src yet
    const initialSrc = await offscreenImage.getAttribute("src")
    // next/image uses a blur placeholder or empty src for lazy images

    // Scroll the image into view
    await offscreenImage.scrollIntoViewIfNeeded()
    await expect(offscreenImage).toBeVisible()

    // Verify the image has loaded (naturalWidth > 0 means the image loaded)
    const naturalWidth = await offscreenImage.evaluate((img: HTMLImageElement) => img.naturalWidth)
    expect(naturalWidth).toBeGreaterThan(0)
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.describe("next/image", () => {
  test("hero image loads with correct attributes", async ({ page }) => {
    await page.goto("/")

    const heroImage = page.getByRole("img", { name: "Hero banner" })
    await expect(heroImage).toBeVisible()

    const srcset = await heroImage.getAttribute("srcset")
    expect(srcset).toBeTruthy()
    expect(srcset).toContain("w=")

    const loading = await heroImage.getAttribute("loading")
    expect(loading).not.toBe("lazy")
  })

  test("offscreen images lazy load on scroll", async ({ page }) => {
    await page.goto("/gallery")

    const offscreenImage = page.getByRole("img", { name: "Gallery item 20" })

    await offscreenImage.scrollIntoViewIfNeeded()
    await expect(offscreenImage).toBeVisible()

    const naturalWidth = await offscreenImage.evaluate((img) => img.naturalWidth)
    expect(naturalWidth).toBeGreaterThan(0)
  })
})
```

### Authentication with NextAuth.js / Auth.js

**Use when**: Testing login flows in Next.js apps using NextAuth.js or Auth.js. Use a setup project to authenticate once, then reuse `storageState` across tests.
**Avoid when**: Your app does not use session-based authentication.

**TypeScript**

```typescript
// playwright.config.ts (auth-specific excerpt)
import { defineConfig } from "@playwright/test"

export default defineConfig({
  projects: [
    {
      name: "setup",
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: "authenticated",
      use: { storageState: "playwright/.auth/user.json" },
      dependencies: ["setup"],
    },
    {
      name: "unauthenticated",
      // No storageState -- tests run as logged-out user
      testMatch: "**/*.unauth.spec.ts",
    },
  ],
})
```

```typescript
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test"

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

setup("authenticate via credentials", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("test@example.com")
  await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!)
  await page.getByRole("button", { name: "Sign in" }).click()

  // Wait for the redirect after successful login
  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  // Save authentication state (cookies + localStorage)
  await page.context().storageState({ path: authFile })
})
```

```typescript
// tests/dashboard.spec.ts
import { test, expect } from "@playwright/test"

// This test runs with the authenticated storageState from the setup project
test("authenticated user sees dashboard", async ({ page }) => {
  await page.goto("/dashboard")

  // No login redirect -- auth cookies are already set
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(page.getByText("test@example.com")).toBeVisible()
})
```

**JavaScript**

```javascript
// tests/auth.setup.js
const { test: setup, expect } = require("@playwright/test")

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

setup("authenticate via credentials", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("test@example.com")
  await page.getByLabel("Password").fill(process.env.TEST_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 })
})
```

```javascript
// tests/dashboard.spec.js
const { test, expect } = require("@playwright/test")

test("authenticated user sees dashboard", async ({ page }) => {
  await page.goto("/dashboard")

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(page.getByText("test@example.com")).toBeVisible()
})
```

## Framework-Specific Tips

### Dev Server vs Production Build

| Scenario          | Command                                               | Trade-off                                                                                                               |
| ----------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Local development | `npm run dev`                                         | Hot reload, fast iteration, but does not test production behavior (minification, optimization, middleware edge runtime) |
| CI pipeline       | `npm run build && npm run start`                      | Tests the real production bundle; catches build errors, middleware edge cases                                           |
| Quick smoke test  | `npm run dev` in CI with `reuseExistingServer: false` | Faster CI but misses production-only bugs                                                                               |

**Recommendation**: Use `npm run dev` locally for fast feedback. Use `npm run build && npm run start` in CI to test the real production artifact.

### Server Components Cannot Be Tested in Isolation

Next.js server components run on the server and produce HTML. Playwright tests the rendered output. You cannot import and render a server component in a Playwright test. Instead:

1. Test the final rendered HTML through navigation (`page.goto`)
2. Verify that server-fetched data appears on the page
3. Use API route tests to validate the data layer separately

### Handling Next.js Redirects

Next.js redirects (configured in `next.config.js`, middleware, or `redirect()` in server actions) are transparent to Playwright. After `page.goto()`, check `page.url()` to verify the final destination.

### Turbopack Compatibility

If using Turbopack (`next dev --turbopack`), update your `webServer.command`:

```typescript
webServer: {
  command: process.env.CI
    ? 'npm run build && npm run start'
    : 'npx next dev --turbopack',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
},
```

### Multiple webServer Entries (Next.js + API Backend)

If your Next.js app consumes a separate backend API:

```typescript
webServer: [
  {
    command: 'npm run dev:api',
    url: 'http://localhost:4000/health',
    reuseExistingServer: !process.env.CI,
  },
  {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
],
```

## Anti-Patterns

| Don't Do This                                                   | Problem                                                                                       | Do This Instead                                                                              |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `await page.waitForTimeout(3000)` after navigation              | Next.js client-side transitions are fast; arbitrary waits are wasteful and fragile            | `await page.waitForURL('/expected-path')` or `await expect(locator).toBeVisible()`           |
| Test `getServerSideProps` by importing and calling it directly  | It depends on `context` (req/res) that Playwright cannot provide; it is a unit test concern   | Navigate to the page and verify the rendered output                                          |
| Mock your own API routes with `page.route()`                    | You are testing a fiction; your API handler may have bugs the mock hides                      | Let the real API route handle requests; mock only external services                          |
| Use `page.goto('http://localhost:3000/path')` with full URL     | Breaks when port or host changes; ignores `baseURL`                                           | Use `page.goto('/path')` and configure `baseURL` in config                                   |
| Run `npm run build && npm run start` locally for every test run | Extremely slow feedback loop during development                                               | Use `npm run dev` locally with `reuseExistingServer: true`; reserve production builds for CI |
| Test `next/image` by checking exact URL paths                   | `next/image` rewrites image URLs through `/_next/image`; paths change between dev and prod    | Assert on `alt` text, visibility, `naturalWidth > 0`, and `srcset` existence                 |
| Skip `.env.test` and hardcode test values in config             | Values scatter across config and test files; hard to maintain                                 | Use `.env.test` for shared test values; `.env.test.local` for secrets                        |
| Test server actions by calling them as functions                | Server actions are bound to the Next.js runtime; calling them outside a request context fails | Trigger server actions through their UI (form submissions, button clicks)                    |
| Ignore console errors during SSR tests                          | Hydration mismatches and server errors appear in the console and indicate real bugs           | Listen for `page.on('console')` errors and fail the test if hydration warnings appear        |

## Related

- [core/configuration.md](configuration.md) -- base Playwright configuration patterns including `webServer`
- [core/authentication.md](authentication.md) -- authentication setup projects and `storageState` reuse
- [core/api-testing.md](api-testing.md) -- testing API routes directly with `request` context
- [core/network-mocking.md](network-mocking.md) -- mocking external APIs that Next.js API routes call
- [core/when-to-mock.md](when-to-mock.md) -- when to mock vs hit real services
- [core/react.md](react.md) -- React-specific patterns that apply to Next.js client components
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup with `npm run build` caching for Next.js
