# Vite SSR hydration errors

> Vite SSR hydration fails when entry-server and entry-client render different output. The setup, the common causes, duplicate React copies, and route tests.

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

Vite SSR hydration works in two halves: `entry-server` renders your app to HTML
with `renderToString` or `renderToPipeableStream`, and `entry-client` calls
`hydrateRoot` on that HTML. A hydration error means the two halves rendered
different output, usually because of the clock, the locale, a browser API, or
different data on each side.

## How Vite SSR hydration works

A Vite SSR app has an `index.html` with a placeholder for the server markup, a
server (`server.js`) that fills it in, and two entry files. The server entry
renders the app:

```tsx title="src/entry-server.tsx"
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import App from "./App";

export function render(url: string) {
  const html = renderToString(
    <StrictMode>
      <App url={url} />
    </StrictMode>
  );
  return { html };
}
```

The client entry hydrates the same tree in the browser:

```tsx title="src/entry-client.tsx"
import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";

hydrateRoot(
  document.getElementById("root")!,
  <StrictMode>
    <App url={window.location.pathname} />
  </StrictMode>
);
```

Everything `App` renders on its first pass in the browser must match what
`render` produced on the server: the same elements, the same text, the same
attributes. The [Vite SSR guide](https://vite.dev/guide/ssr) covers the server
side, and its `create-vite-extra` templates include React.

## Common hydration errors in Vite SSR

A Vite hydration error is React's own message, logged in the browser console.
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. The causes:

| Cause               | Typical code                                                         | Fix                                                              |
| ------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Different trees     | A provider, wrapper or route rendered by one entry and not the other | Render the same `App` with the same props in both entries        |
| Different data      | The server renders with data the client fetches again                | [Server and client data](https://hydration.jscrate.dev/docs/causes/data)                      |
| The clock           | `new Date()` or `Date.now()` in a 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)                         |
| Generated ids       | counters or random ids instead of `useId`                            | [Generated ids](https://hydration.jscrate.dev/docs/causes/unstable-id)                        |

### Two copies of React

If the server bundle and a linked package resolve different copies of `react`,
hooks throw "Invalid hook call" and the page fails instead of hydrating. This
happens with hoisting and with linked packages in monorepos. Vite's
[`resolve.dedupe`](https://vite.dev/config/shared-options#resolve-dedupe)
forces one copy; read its note on SSR builds before relying on it:

```ts title="vite.config.ts"
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  resolve: { dedupe: ["react", "react-dom"] },
});
```

## How to fix them

1. **Render the same tree.** Both entries render one `App` component with the
   same props. Anything the server knows (the URL, the user's locale from a
   header or cookie) goes in as a prop, and the client reads it from the page.
2. **Send the server's data along.** Serialize the data the server rendered
   with into the page and hydrate from it, instead of fetching again in the
   browser. See [different data](https://hydration.jscrate.dev/docs/causes/data).
3. **Read browser values after hydration.** Render a neutral value first and
   change it in `useEffect`. See
   [useEffect and two-pass rendering](https://hydration.jscrate.dev/docs/guides/useeffect-two-pass-rendering).
4. **Use `useId` for ids.** With several React roots on one page, give each its
   own `identifierPrefix`, the same on the server and in `hydrateRoot`.

## Test every route with hydration-proof

hydration-proof starts your server, loads each route in a real browser and
compares the server HTML with the hydrated DOM. The `vite`
[adapter](https://hydration.jscrate.dev/docs/adapters) is picked when `package.json` has `vite` and
`react-dom`, and the project has a `server.js` (or `server.mjs`, `server.ts`)
or a `src/entry-server.*` file:

- **Build and start:** your `build` script, then your `start` script (or
  `serve`, or `preview`). The app is built only when `dist` is missing.
- **Development mode:** your `dev` script, with `--mode development` or
  `--mode both`.
- **Port:** the server must listen on the port hydration-proof chooses. It is
  in the `PORT` environment variable, and `{port}` in a configured command is
  replaced with it.
- **Framework markup:** Vite's client script and the React Refresh preamble
  are never compared.
- **Streaming:** with `renderToPipeableStream`, Suspense boundaries that
  hydrate later are compared on their own.

A Vite app has no file-based routes, so list them in the config. Without any
routes, only `/` is tested:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    paths: ["/", "/about", "/products/1"],
    crawl: true, // also follow links found on those pages
  },
});
```

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

`--sitemap` adds the pages in your sitemap. The
[ESLint plugin](https://hydration.jscrate.dev/docs/eslint) catches the clock, random values and browser
globals in components as you type.

## Related

- [Custom React servers](https://hydration.jscrate.dev/docs/frameworks/custom-server) with Express and
  streaming
- [Astro hydration errors](https://hydration.jscrate.dev/docs/frameworks/astro)
- [What is hydration in React?](https://hydration.jscrate.dev/docs/guides/what-is-hydration)
- [Test every route for hydration errors](https://hydration.jscrate.dev/docs/routes)
- [Adapters](https://hydration.jscrate.dev/docs/adapters)
