Next Talk: Building an AI QA Engineer with Claude Code and Playwright MCP

April 16, 2026 β€” ASQF Quality Night Hamburg

Conference
Skip to content

How to Catch Hydration Errors in Playwright Tests (Astro, Nuxt, React SSR)

Published:Β atΒ 

Your E2E tests pass. The page loads, buttons work.

But open the browser console:

Hydration failed because the server rendered HTML didn't match the client.

This is a hydration mismatch. The server sent one thing and the client replaced it with something else. The page still works, so you don’t notice. Your tests don’t check for it, so they pass.

What are SSR and hydration?#

SSR (server-side rendering) means the server generates HTML and sends it to the browser before JavaScript loads. Users see content before client code boots, and search engines can index it.

Astro and Nuxt build on this model.

Hydration is the next step: client JavaScript takes over the server-rendered HTML, attaching event handlers and state to the existing markup. The contract: the first client render must match what the server sent.

When it does not match, the framework discards the server HTML and re-renders on the client. That re-render is a hydration mismatch.

How SSR Hydration Works Server renders HTML Browser displays page Client JS hydrates DOM ? YES NO Clean handoff Framework re-renders

Common causes#

Anything that produces different HTML on client and server:

Theme toggles and locale formatting cause most of them.

Common Causes of Hydration Mismatches Browser APIs localStorage, matchMedia localStorage.getItem('theme') window.matchMedia('(prefers-…)') Non-deterministic Date(), Math.random() new Date().toLocaleString() Math.random().toString(36) Aa Formatting dates & numbers differ price.toLocaleString('en-US') Intl.DateTimeFormat(locale) server locale β‰  client locale Conditional Rendering browser-only state branches isMounted && <ClientOnly /> typeof window !== 'undefined' server renders null, client renders UI

If you use Vue with SSR, the window is not defined error comes from the same root cause. VueUse has a pattern for it:

How VueUse Solves SSR Window Errors in Vue Applications How VueUse Solves SSR Window Errors in Vue Applications Discover how VueUse solves SSR issues with browser APIs and keeps your Vue composables safe from 'window is not defined' errors. vue

A real bug I found#

I was working on an Astro page and my theme hook was reading browser state during the first render:

function getInitialTheme(): Theme {
  const stored = localStorage.getItem(SITE.themeStorageKey);
  if (stored === "light" || stored === "dark") return stored;
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

export function useTheme() {
  const [theme, setTheme] = useState<Theme>(getInitialTheme);
}

The server defaulted to dark, but the browser picked light. React saw the mismatch, logged a hydration warning, and re-rendered from scratch.

The page still worked, the button still existed. Normal E2E tests passed.

The fix: start with a deterministic value, resolve browser state after mount.

export function useTheme() {
  const [theme, setTheme] = useState<Theme>("dark");
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const preferredTheme = getPreferredTheme();
    document.documentElement.classList.toggle("dark", preferredTheme === "dark");
    setTheme(preferredTheme);
    setMounted(true);
  }, []);

  useEffect(() => {
    if (!mounted) return;
    const root = document.documentElement;
    root.classList.toggle("dark", theme === "dark");
    localStorage.setItem(SITE.themeStorageKey, theme);
  }, [mounted, theme]);

  return { theme, setTheme, toggleTheme: () => setTheme((t) => (t === "dark" ? "light" : "dark")) };
}

The core idea#

Listen to the browser console during a Playwright test. If a hydration warning appears, fail the test.

React and Vue log hydration mismatches to the console. You don’t check the console during automated tests, so this fixture does.

The fixture#

What are Playwright fixtures?

Fixtures are Playwright’s way of setting up and tearing down what each test needs. Built-in fixtures like page and browser come for free. You create custom ones with base.extend(). Each fixture runs when a test requests it and gets cleaned up afterward. The fixture below injects hydrationErrors and runtimeErrors into every test that asks for them.

I first saw this approach in the npmx.dev open source project and adapted it for my Astro site. My version covers React and Vue hydration strings and catches uncaught runtime exceptions:

import { expect, test as base, type ConsoleMessage } from "@playwright/test";

const HYDRATION_ERROR_PATTERNS = [
  /hydration failed because the server rendered html didn't match the client/i,
  /hydration completed but contains mismatches/i,
  /hydration text content mismatch/i,
  /hydration node mismatch/i,
  /hydration attribute mismatch/i,
];

function isHydrationError(text: string): boolean {
  return HYDRATION_ERROR_PATTERNS.some((pattern) => pattern.test(text));
}

function toConsoleText(message: ConsoleMessage): string {
  return message.text().trim();
}

export const test = base.extend<{
  hydrationErrors: string[];
  runtimeErrors: string[];
}>({
  hydrationErrors: async ({ page }, use) => {
    const hydrationErrors: string[] = [];

    const handleConsole = (message: ConsoleMessage) => {
      const text = toConsoleText(message);
      if (isHydrationError(text)) {
        hydrationErrors.push(text);
      }
    };

    page.on("console", handleConsole);
    await use(hydrationErrors);
    page.off("console", handleConsole);
  },

  runtimeErrors: async ({ page }, use) => {
    const runtimeErrors: string[] = [];

    const handleConsole = (message: ConsoleMessage) => {
      const text = toConsoleText(message);
      if (message.type() === "error" && text.length > 0 && !isHydrationError(text)) {
        runtimeErrors.push(text);
      }
    };

    const handlePageError = (error: Error) => {
      runtimeErrors.push(error.message);
    };

    page.on("console", handleConsole);
    page.on("pageerror", handlePageError);
    await use(runtimeErrors);
    page.off("console", handleConsole);
    page.off("pageerror", handlePageError);
  },
});

export { expect };

Drop this into test/e2e/test-utils.ts and import from there instead of @playwright/test.

Page loads 1 ... Console event 2 Regex match? 3 hydration 4a hydrationErrors[] collected into array runtime 4b runtimeErrors[] collected into array 5 assert [] Both arrays must be empty for the test to pass

Related: a full AI-driven QA workflow with Playwright:

Building an AI QA Engineer with Claude Code and Playwright MCP Building an AI QA Engineer with Claude Code and Playwright MCP Learn how to build an automated QA engineer using Claude Code and Playwright MCP that tests your web app like a real user, runs on every pull request, and writes detailed bug reports. aitestingclaude-code +1

Using it#

import { expect, test } from "./test-utils";

test("home page hydrates cleanly", async ({ page, hydrationErrors, runtimeErrors }) => {
  await page.goto("/", { waitUntil: "domcontentloaded" });
  await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();

  expect(hydrationErrors).toEqual([]);
  expect(runtimeErrors).toEqual([]);
});

Start with your homepage. Add one interactive route, then one with a theme toggle or client-only widget. That surfaces most bugs.

How npmx.dev does it at scale#

The npmx.dev project tests hydration correctness for every combination of user settings across every page, around 48 checks from a single fixture.

They inject localStorage values via Playwright’s page.addInitScript() before navigation, simulating a returning user with saved preferences. Returning users with non-default settings trigger most hydration mismatches.

const PAGES = ["/", "/about", "/settings", "/compare", "/search", "/package/nuxt"];

test.describe("color mode: dark", () => {
  for (const page of PAGES) {
    test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
      await injectLocalStorage(pw, { "npmx-color-mode": "dark" });
      await goto(page, { waitUntil: "hydration" });

      expect(hydrationErrors).toEqual([]);
    });
  }
});

async function injectLocalStorage(page: Page, entries: Record<string, string>) {
  await page.addInitScript((e: Record<string, string>) => {
    for (const [key, value] of Object.entries(e)) {
      localStorage.setItem(key, value);
    }
  }, entries);
}

They repeat this for every setting type, locale, accent color, background theme, package manager, relative dates, each with a non-default value. If any combination causes a hydration mismatch on any page, the test fails.

/ /about /settings /compare /search /package/nuxt Color mode βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ Locale βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ Accent color βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ Background βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ Pkg manager βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ Relative dates βœ“ βœ“ βœ“ βœ“ βœ“ βœ“ 6 pages Γ— 6 settings 36 checks from 1 fixture

Their fixture uses Vue-specific error strings ("Hydration completed but contains mismatches") while mine uses React patterns. The approach is the same, only the strings you match against change.

More on how E2E tests relate to unit and integration tests:

Vue 3 Testing Pyramid: A Practical Guide with Vitest Browser Mode Vue 3 Testing Pyramid: A Practical Guide with Vitest Browser Mode Learn a practical testing strategy for Vue 3 applications using composable unit tests, Vitest browser mode integration tests, and visual regression testing. vuetestingvitest +2

If you ship an SSR app and do not check for hydration errors in your browser tests, you have one in production right now.

Press Esc or click outside to close

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now