# Astro hydration errors

> An Astro hydration error comes from a React island whose server HTML differs from its first client render. Causes, client directives, useId, and tests.

Source: https://hydration.jscrate.dev/docs/frameworks/astro
Last updated: 2026-09-18

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:

```text
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](https://hydration.jscrate.dev/docs/errors) 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](https://hydration.jscrate.dev/docs/causes/time)                                                 |
| Locale and timezone | `toLocaleString()` without an explicit locale or `timeZone` | [Locale](https://hydration.jscrate.dev/docs/causes/locale), [timezone](https://hydration.jscrate.dev/docs/causes/timezone)          |
| Browser-only APIs   | `window`, `localStorage` or `matchMedia` read during render | [Browser APIs](https://hydration.jscrate.dev/docs/causes/browser-api), [storage](https://hydration.jscrate.dev/docs/causes/storage) |
| Random values       | `Math.random()`, `crypto.randomUUID()` in render            | [Random values](https://hydration.jscrate.dev/docs/causes/random)                                      |
| Duplicate ids       | `useId` in several islands without an `identifierPrefix`    | [Generated ids](https://hydration.jscrate.dev/docs/causes/unstable-id)                                 |
| Invalid HTML        | `<div>` inside `<p>`, `<a>` inside `<a>` in the component   | [Invalid nesting](https://hydration.jscrate.dev/docs/causes/invalid-html)                              |

## 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.

```astro title="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:

```tsx title="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](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/adapters) 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.

```bash
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test
```

Give dynamic pages example values, or they are skipped:

```ts title="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](https://hydration.jscrate.dev/docs/scenarios) or an
[environment matrix](https://hydration.jscrate.dev/docs/environment-matrix).

## Related

- [Vite SSR hydration](https://hydration.jscrate.dev/docs/frameworks/vite-ssr)
- [Generated ids that differ](https://hydration.jscrate.dev/docs/causes/unstable-id)
- [HP3004: two React roots generate the same ids](https://hydration.jscrate.dev/docs/issues/hp3004)
- [Client-only components](https://hydration.jscrate.dev/docs/guides/client-only-component)
- [Adapters](https://hydration.jscrate.dev/docs/adapters)
