Hydration Proof

Search documentation

Find a page or section

React islands, one root at a time.

An Astro hydration error can happen when a React island renders different output on the server and in the browser. Look inside the island for dates, locale formatting, browser-only values, random numbers, or generated ids. Astro's own markup does not hydrate.

Common hydration errors in Astro

An Astro React hydration error uses React's own wording. With React 19, you may see:

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.

See React hydration error messages for React 18 wording and 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, it stays static. Add a client:* directive when the component needs to become interactive:

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 island is a separate React root. A mismatch in one does not make the other islands re-render.

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

Compute server-known values in the .astro page and pass them as props. Astro sends those props to the browser, so the island can start with the same value:

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

Read browser values after hydration

For a browser-only value, render a stable initial value. Read the browser in useEffect after hydration:

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

If the component cannot render on the server, use client:only="react". Its content will appear only after JavaScript loads, so provide an appropriate fallback for the page.

Keep useId unique across islands

Astro's React integration gives each island an id prefix. If you create React roots yourself, use a distinct identifierPrefix for each root and the same prefix for that root's server render and hydrateRoot call. hydration-proof reports collisions as HP3004.

Test every route with hydration-proof

hydration-proof checks each island in a browser. It selects the astro adapter when it finds an astro dependency or astro.config.*:

  • 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

Provide example values for dynamic pages so the check can visit them:

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

To check other locales, timezones, or screen sizes, add scenarios or an environment matrix.