Fixing Hydration Mismatch Errors in Vite SSR
A hydration mismatch means the HTML your Vite server rendered does not match what the client component tree produces on first render, and the framework throws away the server markup to re-render from scratch—erasing the SSR benefit and sometimes corrupting the DOM. This guide, sitting under Vite SSR and SSG Integration, walks the exact diagnosis: reproduce the warning, read the mismatch diff React or Vue prints, isolate the non-deterministic source, and apply the narrowest fix that keeps both renders identical.
Problem Scope
The component renders correctly on the server and correctly in the browser, but React logs Hydration failed because the server rendered HTML didn't match the client (or Vue logs Hydration node mismatch), and the page flashes or loses interactivity. The single rule being violated: the server’s HTML and the client’s first render must be identical, because hydration adopts existing DOM nodes rather than building them.
Prerequisites & Reproducible Setup
You need a working Vite SSR server first; the middleware pipeline is covered in Configuring Vite SSR with Express and Node.js. Reproduce the warning with a deliberately non-deterministic component.
# Node 20.10+, Vite 6.x
npm install express vite @vitejs/plugin-react react react-dom
npm install --save-dev tsx
// src/Clock.tsx — React 18.x, intentionally broken
export function Clock() {
// Date.now() differs between server render and client render:
return <p>Rendered at {new Date().toLocaleTimeString()}</p>;
}
// src/entry-server.tsx — React 18.x
import { renderToString } from 'react-dom/server';
import { Clock } from './Clock';
export async function render() {
return { html: renderToString(<Clock />) };
}
// src/entry-client.tsx — React 18.x
import { hydrateRoot } from 'react-dom/client';
import { Clock } from './Clock';
hydrateRoot(document.getElementById('app')!, <Clock />);
Load the page and open the console: the server stamped one timestamp into the HTML, the client computed a later one, and the text nodes diverge.
Diagnosis Workflow
- Read the diff, not just the headline. React 18 prints the offending element and both values, e.g.
Warning: Text content did not match. Server: "10:42:01 AM" Client: "10:42:03 AM". Vue printsHydration text mismatch ... - rendered on server: "10:42:01" - expected on client: "10:42:03". The two quoted strings tell you exactly which node and which value pair to reconcile. - Classify the source. A changing number or timestamp points at
Date/Math.random/crypto.randomUUID. A node that exists only client-side points at atypeof window !== 'undefined'branch orlocalStorage/matchMediaread. A whole subtree shifting by one node points at invalid HTML nesting (<p>inside<p>,<div>inside<table>) that the browser’s parser silently corrected before hydration ran. - Confirm it is render-time, not effect-time. Move the suspect expression behind a
console.trace()and check whether it fires during the synchronous render pass. Reads that happen inuseEffect/onMountedcannot cause a mismatch because they run after hydration. - Reproduce deterministically. Disable network data and reload twice; if the mismatch persists with static props, the divergence is local (time/random/browser API), not data-driven.
Solution
The fix is always the same shape: make the first client render produce the same markup the server did, then update to the live value after hydration. Defer the non-deterministic read into an effect so the initial render matches the server’s null/placeholder output.
// src/Clock.tsx — React 18.x, fixed
import { useEffect, useState } from 'react';
export function Clock() {
// Render nothing time-specific on the server AND on the first client render:
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
// Runs only in the browser, only after hydration — no mismatch:
setTime(new Date().toLocaleTimeString());
}, []);
return <p>Rendered at {time ?? '—'}</p>;
}
Because time starts null on both server and first client render, the HTML matches; the effect then swaps in the live value as a normal client update. The Vue equivalent uses the same deferral:
Rendered at {{ time ?? '—' }}
For values that are intentionally allowed to differ (a server-stamped timestamp you want to display verbatim), opt out per-element rather than deferring. React offers suppressHydrationWarning on the single element; it silences exactly one level and does not cascade:
// React 18.x — only when divergence is expected and acceptable:
<time suppressHydrationWarning>{serverRenderedTimestamp}</time>
When the mismatch is data-driven—the server fetched a list the client did not have—the fix is to serialize the server’s data into the HTML payload and read it on the client instead of refetching, so both renders start from identical state:
// src/entry-server.tsx — React 18.x, data serialized
import { renderToString } from 'react-dom/server';
import { App } from './App';
export async function render(url: string) {
const data = await loadData(url); // server fetch
const html = renderToString(<App data={data} />);
return { html, state: data }; // state goes into the payload
}
// src/entry-client.tsx — React 18.x, reads the serialized state
import { hydrateRoot } from 'react-dom/client';
import { App } from './App';
const state = JSON.parse(document.getElementById('__STATE__')!.textContent!);
hydrateRoot(document.getElementById('app')!, <App data={state} />);
Verification
- Reload twice; the console shows no
Hydration failed/Hydration node mismatchwarning. - In React, build the client with
--mode productionand confirm the deferred component renders the placeholder in the raw HTML:curl -s localhost:3000/ | grep -- '—'matches, proving the server emitted the neutral value. - Throttle the network in DevTools and reload: the page is interactive immediately (event handlers attached) rather than after a visible re-render flash, confirming hydration adopted the DOM instead of replacing it.
- For data-driven cases, diff
renderToStringoutput against the live DOM by logging both; they should be byte-identical before the first effect fires.
Gotchas & Edge Cases
- Whitespace and text-node boundaries. A stray space between JSX expressions or
{' '}that exists on one side creates a text-node mismatch even when the visible content looks identical. Inspect the raw HTML, not the rendered page. - Invalid nesting auto-corrected by the parser.
<p>wrapping a<div>, or a<tr>outside a<tbody>, gets reshaped by the browser before hydration, so the client tree no longer lines up with the server string. Fix the markup;suppressHydrationWarningwill not help here. suppressHydrationWarningis one level deep. It silences the element you put it on, not its descendants. Putting it on a wrapper to mute a deep mismatch hides the real bug without fixing the discarded subtree.- Locale and timezone drift.
toLocaleStringformats with the server’sIntldefaults and the browser’s locale, so even a “static” date diverges. Format with an explicittimeZone/localeor defer the read—do not assume a fixed timestamp is safe.
Related
- Vite SSR and SSG Integration — the dual-graph and hydration model this warning comes from.
- Configuring Vite SSR with Express and Node.js — the server pipeline that produces the markup being hydrated.
- Vite Configuration & Ecosystem —
import.meta.env.SSRand build modes used in the guards above.