Hydration Proof

Search documentation

Find a page or section

React islands, one root at a time.

An Astro hydration error comes from a React island: a component with a client:* directive whose server-rendered HTML differs from what React renders when the island hydrates in the browser. Astro's own markup never hydrates, so look inside the island for dates, locale formatting, browser APIs, random values and ids.

Common hydration errors in Astro

An Astro React hydration error is React's own message, logged in the browser console for the island that failed. With React 19:

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.

All React hydration error messages lists the React 18 wording and the minified production codes.

How does Astro island hydration work?

A React component in an .astro page renders to HTML on the server. Without a client directive, that HTML is all the browser gets: no JavaScript, and nothing to hydrate. A directive makes it an island that hydrates on its own:

DirectiveWhen the island hydrates
client:loadImmediately on page load
client:idleOnce the page has finished its initial load and requestIdleCallback fires
client:visibleOnce the component enters the viewport
client:only="react"Never: the server renders no HTML, and the component renders only in the browser

Each <astro-island> is its own React root. A mismatch in one island does not affect the others, and React reports it for that island alone.

Why does an Astro hydration mismatch happen?

CauseTypical codeFix
The clocknew Date() or Date.now() in the componentTime
Locale and timezonetoLocaleString() without an explicit locale or timeZoneLocale, timezone
Browser-only APIswindow, localStorage or matchMedia read during renderBrowser APIs, storage
Random valuesMath.random(), crypto.randomUUID() in renderRandom values
Duplicate idsuseId in several islands without an identifierPrefixGenerated ids
Invalid HTML<div> inside <p>, <a> inside <a> in the componentInvalid nesting

How to fix an Astro hydration error

Pass values from the page as props

The frontmatter of an .astro page runs only on the server. Compute a value there and pass it to the island: Astro serializes the props into the page, so the island hydrates with the same value the server rendered.

src/pages/index.astro
---
import LastUpdated from "../components/LastUpdated.tsx";
 
const renderedAt = Date.now();
---
 
<LastUpdated client:load renderedAt={renderedAt} />

Read browser values after hydration

Render output that does not depend on the browser, then change it in useEffect, which only runs after the island has hydrated:

src/components/Greeting.tsx
import { useEffect, useState } from "react";
 
export default function Greeting() {
  const [name, setName] = useState<string | null>(null);
 
  useEffect(() => {
    setName(localStorage.getItem("name"));
  }, []);
 
  return <p>Hello{name ? `, ${name}` : ""}</p>;
}

Skip server rendering with client:only

For a component that cannot render on the server at all, use client:only="react". There is no server HTML to compare, so there is no mismatch, but the component's content only appears once its JavaScript has run.

Keep useId unique across islands

Two React roots that both call useId generate the same ids unless each has its own identifierPrefix. Astro's React integration sets one per island. If you render React roots yourself, pass the same identifierPrefix to the server render and to hydrateRoot, and a different one for each root. hydration-proof reports a collision as HP3004.

Test every route with hydration-proof

hydration-proof loads every page in a real browser and compares each island's server HTML with its hydrated DOM. The astro adapter is picked when package.json has astro or the project has an astro.config.* file:

  • Build and start: astro build (or your build script when it runs astro). With @astrojs/node, the built server runs as node ./dist/server/entry.mjs; otherwise astro preview serves the build.
  • Development mode: astro dev, with --mode development or --mode both.
  • Routes: from src/pages. Astro's [id] and [...slug] are already the syntax routes.dynamic uses.
  • Islands: each island is compared on its own. The attributes Astro changes while it loads an island (ssr, props, client and others), its loader script and the dev toolbar are never compared.
  • Pages without React are normal in an Astro site and are not reported.
  • Not-found page: a URL that does not exist is loaded too, to check that the not-found page hydrates.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test

Give dynamic pages example values, or they are skipped:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  routes: {
    dynamic: { "/blog/[slug]": ["hello-world"] },
  },
});

To test islands in other locales, timezones or screen sizes, add scenarios or an environment matrix.