Testing React Server Components with Playwright

React Server Components (RSC) execute on the server, never ship their code to the browser, and stream a serialized render payload to the client — which makes them awkward to test with a tool built to mount components in a browser. This guide is for React 18/19 and Next.js App Router developers using Playwright Component Testing who hit the wall where mount() cannot render an async server component. It maps exactly which RSC boundaries Playwright CT can verify, which ones demand a full end-to-end run against a real server, and how to split a feature so each layer is tested by the right tool. Version scope: @playwright/experimental-ct-react 1.4x, React 19, Next.js App Router.

Root Cause Analysis

Playwright CT mounts a component inside a Vite-bundled browser sandbox. That model assumes the component is client code: it can be bundled, shipped to the page, and rendered by React in the DOM. A Server Component breaks every one of those assumptions. It may be an async function that awaits a database query or fetch; it has no client bundle; and React renders it to the RSC wire format on the server, not in the browser.

When you try to mount() an async server component, the Vite sandbox bundles it as if it were client code. There is no server runtime to await its data, no Next.js request context, and no streaming pipeline — so the mount either throws or silently renders an empty shell. The deeper issue is the RSC boundary: a server component tree typically embeds client components marked with 'use client', and only those client islands are real browser code. Playwright CT is excellent at the islands and blind to the server trunk. Recognizing that split is the whole job.

Reproducible Setup

Consider a typical App Router page: a server component fetches data and passes it to an interactive client child.

// app/dashboard/MetricsPanel.tsx  — Server Component (no 'use client')
import { ActiveUsersChart } from './ActiveUsersChart';

async function getMetrics() {
  const res = await fetch('https://api.internal/metrics', { cache: 'no-store' });
  return res.json() as Promise<{ activeUsers: number; series: number[] }>;
}

export async function MetricsPanel() {
  const metrics = await getMetrics();
  return (
    <section aria-label="Metrics">
      <h2>Active users: {metrics.activeUsers}</h2>
      <ActiveUsersChart series={metrics.series} />
    </section>
  );
}
// app/dashboard/ActiveUsersChart.tsx  — Client Component
'use client';
import { useState } from 'react';

export function ActiveUsersChart({ series }: { series: number[] }) {
  const [hovered, setHovered] = useState<number | null>(null);
  const peak = Math.max(...series);
  return (
    <div role="img" aria-label={`Chart, peak ${peak}`}>
      {series.map((v, i) => (
        <button key={i} onMouseEnter={() => setHovered(i)} aria-pressed={hovered === i}>
          {v}
        </button>
      ))}
    </div>
  );
}

MetricsPanel is server-only and async; ActiveUsersChart is a client island. These two files need two different test strategies.

Implementation

Test the client island with Playwright CT

ActiveUsersChart is real browser code with props, state, and events — exactly what CT is for. Mount it directly and drive the interactions. Treat the server component’s job (fetching series) as an input you provide as a prop fixture.

// app/dashboard/ActiveUsersChart.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { ActiveUsersChart } from './ActiveUsersChart';

test('marks the hovered bar as pressed', async ({ mount }) => {
  const component = await mount(<ActiveUsersChart series={[3, 8, 5]} />);
  const bars = component.getByRole('button');
  await expect(bars).toHaveCount(3);

  await bars.nth(1).hover();
  await expect(bars.nth(1)).toHaveAttribute('aria-pressed', 'true');
  await expect(component).toHaveAttribute('aria-label', 'Chart, peak 8');
});

This verifies the interactive contract deterministically, with no server runtime involved. The pattern is the core takeaway: extract the interactive surface into a client component and test that surface in CT, passing server-derived data as props.

Test the async server boundary with a full end-to-end run

The server component’s behavior — awaiting getMetrics(), rendering activeUsers, composing the client island, streaming through Suspense — only exists when a real server runs the App Router. That requires Playwright’s standard end-to-end runner (@playwright/test) pointed at next dev or next start, not CT.

// e2e/dashboard.spec.ts  — standard Playwright test, real server
import { test, expect } from '@playwright/test';

test('server component renders fetched metrics and hydrates the chart', async ({ page }) => {
  // Stub the upstream API the SERVER calls by routing at the browser is NOT enough —
  // for server-side fetch you point the app at a test backend via env, then:
  await page.goto('/dashboard');

  await expect(page.getByRole('heading', { name: /Active users:/ })).toBeVisible();
  await expect(page.getByRole('img', { name: /Chart, peak/ })).toBeVisible();

  // Confirm the client island hydrated and is interactive after the server stream.
  await page.getByRole('button').first().hover();
  await expect(page.getByRole('button').first()).toHaveAttribute('aria-pressed', 'true');
});

A critical subtlety: page.route() intercepts requests the browser makes, but getMetrics() runs on the server. Browser-level routing cannot see server-side fetches — point the app at a disposable test backend through configuration instead. That asymmetry is covered in depth in mocking network in Playwright component tests.

Decide the split deliberately

Concern Right tool
Client island props, state, events, accessibility Playwright CT (mount)
Async server data fetch and server render End-to-end run against a real server
Suspense streaming and hydration handoff End-to-end run
Pure formatting / utility logic Vitest unit test

The architectural rule: push interactive logic into client components so CT can own it, and reserve the slower end-to-end tier for the genuinely server-bound behavior. This keeps most of your coverage in the fast band and follows the same test pyramid strategy that governs the rest of the suite.

Verification

Confirm the split is correct by checking three things. First, every mount() target compiles without a server runtime — if a spec needs await on a database or server fetch, it is in the wrong tier. Second, the end-to-end suite asserts on rendered, accessible output (getByRole, getByText) rather than on RSC internals, because the wire format is an implementation detail you should never assert against directly. Third, hydration actually happened: after the server stream paints, an interaction on the client island must change state, proving the island wired up rather than rendering as inert server HTML.

// hydration smoke check inside the e2e spec
await page.goto('/dashboard');
const firstBar = page.getByRole('button').first();
await firstBar.click();
await expect(firstBar).toHaveAttribute('aria-pressed', 'true'); // proves hydration

If that assertion passes only after a manual wait, you likely have a hydration mismatch worth isolating with a dedicated hydration debugging workflow.

Troubleshooting

When mount() throws on a server component, the fix is almost never a config tweak — it is recognizing you are testing server code and moving the assertion to the end-to-end tier. When the chart renders but never reacts to clicks in an end-to-end run, hydration failed: check for a server/client markup mismatch or a missing 'use client' directive. When server-side data never appears, your stub targeted the browser instead of the server — route through a test backend via environment configuration. When streaming Suspense fallbacks flash and cause flaky assertions, await the final state with a web-first matcher rather than asserting during the stream.

FAQ

Can Playwright CT mount an async Server Component directly?

No. CT bundles the component as client code and runs it in a browser sandbox, but an async Server Component needs a server runtime to await its data and produce the RSC payload. The supported approach is to test the interactive 'use client' islands with CT and verify the server component’s rendered output with a full end-to-end run against next dev or next start.

How do I mock the data a Server Component fetches?

Because the fetch runs on the server, page.route() in the browser cannot intercept it. Point the application at a disposable test backend through environment variables (for example an API_BASE_URL your server fetch reads), or run a local stub server the App Router talks to. Browser-level routing only helps for fetches the client island itself issues after hydration.

When should I use CT versus a full end-to-end test for RSC features?

Use CT for anything interactive that ships to the browser — props, state, events, accessibility on client components. Use an end-to-end run for the server fetch, the server render, Suspense streaming, and the hydration handoff. Keep pure logic in Vitest. The goal is to keep most coverage in the fast CT band and spend the slower end-to-end budget only on genuinely server-bound behavior.

Does this change with the Next.js App Router specifically?

The App Router is the most common place you meet this boundary because it makes Server Components the default, but the principle is general to any RSC setup. The same split applies: client islands to CT, async server trunks to an end-to-end run. App Router specifics like route segment caching and cache: 'no-store' only affect the server-tier test, not the CT spec for the island.