Playwright Component Testing with Next.js and Server Components

Playwright Component Testing with Next.js and Server Components

Playwright Component Testing (CT) runs your components inside a real browser, mounted in an iframe served by a Vite dev server. That's the key detail that explains why React Server Components cause problems: RSC requires a Node.js runtime to execute, and there is no Node.js inside a browser iframe. The two mental models collide immediately when you try to mount() a Server Component.

Understanding where the boundary sits lets you build a testing strategy that actually works instead of fighting the tool.

How @playwright/experimental-ct-react Works

When you run playwright test --config=playwright-ct.config.ts, Playwright spins up a Vite server for each worker. Test files import the mount helper from @playwright/experimental-ct-react, which serializes your component JSX and props, sends them to the iframe, and Vite's React plugin handles the render. The browser executes the component the same way it would in production — DOM events work, hooks fire, context providers resolve.

The full Playwright browser automation API is available. You can page.click(), page.fill(), expect(component).toHaveText(), and so on. The component is live in a real browser, not a jsdom simulation.

Setup is minimal:

npm init playwright@latest -- --ct

This creates playwright-ct.config.ts and a playwright/index.html with a mounting point. For Next.js, you'll want to install React 18+ and ensure Vite can resolve your component imports.

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './',
  snapshotDir: './__snapshots__',
  timeout: 10_000,
  use: {
    ctPort: 3100,
    ctViteConfig: {
      resolve: {
        alias: {
          '@': '/src',
        },
      },
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

The Server Components Problem

A Server Component looks like any React component from the outside:

// app/components/UserProfile.tsx
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.users.findById(userId); // server-side DB call
  return <div>{user.name}</div>;
}

Try to mount(UserProfile) in a CT test and you hit two walls. First, the async function returning JSX is not valid React on the client — the React 18 client renderer doesn't know how to handle server component protocols. Second, db.users.findById is a Node.js call that doesn't exist in the browser.

Playwright CT cannot run Server Components. This is not a bug or a gap that will be patched — it's a fundamental architectural constraint.

What You Can Test with CT

The practical answer for Next.js App Router codebases is: split your component tree deliberately so the parts worth unit-testing are Client Components.

Any component with "use client" at the top is a valid CT target. This covers the overwhelming majority of interactive UI: forms, modals, tabs, dropdowns, data tables with sorting and filtering, custom hooks surfaced through components, animation states. These are also the components where Playwright CT pays off most — you get real browser rendering, real event dispatch, real CSS.

// components/SearchBox.tsx
"use client";

import { useState } from "react";

export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const [value, setValue] = useState("");

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSearch(value);
      }}
    >
      <input
        data-testid="search-input"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Search..."
      />
      <button type="submit">Search</button>
    </form>
  );
}
// SearchBox.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { SearchBox } from './SearchBox';

test('calls onSearch with input value on submit', async ({ mount }) => {
  let searched = '';
  const component = await mount(
    <SearchBox onSearch={(q) => { searched = q; }} />
  );

  await component.getByTestId('search-input').fill('playwright');
  await component.getByRole('button', { name: 'Search' }).click();

  expect(searched).toBe('playwright');
});

test('clears input after search', async ({ mount }) => {
  const component = await mount(
    <SearchBox onSearch={() => {}} />
  );

  await component.getByTestId('search-input').fill('test query');
  await component.getByRole('button', { name: 'Search' }).click();

  // If your component resets state on submit, verify it here
  await expect(component.getByTestId('search-input')).toHaveValue('');
});

Testing Pages and Routes with Full E2E

Server Components — layouts, pages, data-fetching components — belong in end-to-end tests against a running Next.js server. The right tool is the standard playwright.config.ts targeting localhost:3000.

// playwright.config.ts (E2E, separate from CT config)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'next dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
// e2e/user-profile.spec.ts
import { test, expect } from '@playwright/test';

test('displays user profile from database', async ({ page }) => {
  await page.goto('/users/123');
  await expect(page.getByRole('heading')).toContainText('Jane Doe');
});

This tests the full server component pipeline: the async function runs on the Node.js server, the rendered HTML lands in the browser, and Playwright asserts on the result.

Running Both CT and E2E in the Same Repo

Most projects want both. The cleanest setup uses two separate config files and a package.json script for each:

{
  "scripts": {
    "test:ct": "playwright test --config=playwright-ct.config.ts",
    "test:e2e": "playwright test --config=playwright.config.ts",
    "test": "npm run test:ct && npm run test:e2e"
  }
}

Keep CT tests colocated with components (SearchBox.ct.spec.tsx next to SearchBox.tsx) and E2E tests in a top-level e2e/ directory. This makes the intent of each test obvious and avoids config conflicts.

One common gotcha: the CT config's testDir should not overlap with the E2E config's testDir, or you'll get tests picked up by the wrong runner. Using .ct.spec.tsx as the CT file extension and .spec.ts for E2E, combined with separate testMatch patterns, keeps them apart.

// playwright-ct.config.ts
export default defineConfig({
  testMatch: '**/*.ct.spec.tsx',
  // ...
});

// playwright.config.ts
export default defineConfig({
  testMatch: '**/*.spec.ts',
  // ...
});

Providers and Context in CT

Real applications wrap components in providers: theme providers, query clients, auth context. The CT setup file handles this globally:

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../components/ThemeProvider';

beforeMount(async ({ App }) => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <App />
      </ThemeProvider>
    </QueryClientProvider>
  );
});

Every component mounted in CT now has access to React Query and your theme. You can override providers per-test by wrapping in the mount call directly:

const component = await mount(
  <SpecialProvider value="test">
    <MyComponent />
  </SpecialProvider>
);

The Practical Division

The testing architecture that works for Next.js App Router projects:

  • CT tests: Client Components with interaction logic, custom hooks, form validation, UI state machines, accessible component behavior (keyboard navigation, ARIA states)
  • E2E tests: Full pages, data fetching, authentication flows, routing behavior, anything involving Server Components or server actions

Server Components are usually thin rendering shells over data that was fetched by the framework. The interesting logic — the user-facing behavior — lives in Client Components. That's where Playwright CT provides the most value: fast, isolated, browser-accurate tests for the components users actually interact with.

If you find yourself wanting to CT test something that requires a database call, that's a signal the component needs to be split: extract the data fetching into a Server Component, push the interactive UI into a Client Component, and test the Client Component with mocked data props.

Tools like HelpMeTest complement this by covering the integrated paths — full user flows across multiple pages — that neither CT nor narrowly-scoped E2E tests capture efficiently.

The constraint of "CT can't run Server Components" turns out to be useful design pressure toward a cleaner component architecture.

Start now free