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:
| 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 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?
| 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
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:
---
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:
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 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 testProvide example values for dynamic pages so the check can visit them:
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.