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:
| Directive | When the island hydrates |
|---|---|
client:load | Immediately on page load |
client:idle | Once the page has finished its initial load and requestIdleCallback fires |
client:visible | Once 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?
| Cause | Typical code | Fix |
|---|---|---|
| The clock | new Date() or Date.now() in the component | Time |
| Locale and timezone | toLocaleString() without an explicit locale or timeZone | Locale, timezone |
| Browser-only APIs | window, localStorage or matchMedia read during render | Browser APIs, storage |
| Random values | Math.random(), crypto.randomUUID() in render | Random values |
| Duplicate ids | useId in several islands without an identifierPrefix | Generated ids |
| Invalid HTML | <div> inside <p>, <a> inside <a> in the component | Invalid 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.
---
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:
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 yourbuildscript when it runsastro). With@astrojs/node, the built server runs asnode ./dist/server/entry.mjs; otherwiseastro previewserves the build. - Development mode:
astro dev, with--mode developmentor--mode both. - Routes: from
src/pages. Astro's[id]and[...slug]are already the syntaxroutes.dynamicuses. - Islands: each island is compared on its own. The attributes Astro changes
while it loads an island (
ssr,props,clientand 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 testGive dynamic pages example values, or they are skipped:
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.