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.
The reason this class of bug is so common in Vite specifically is that Vite runs your component code twice through two different module graphs. The server graph is loaded through ssrLoadModule, executes in Node against renderToString, and produces a static HTML string. The client graph is served as native ESM, executes in the browser against hydrateRoot, and expects to adopt the DOM that string produced rather than rebuild it. Nothing enforces that these two executions agree—they share source files but not a runtime, not a clock, and not a global object. Any value that is read at render time and differs between Node and the browser silently splits the two outputs, and the mismatch only surfaces at hydration, well after the code that caused it has run.
The cost of ignoring it is not cosmetic. When React or Vue detects that the adopted DOM does not match, it discards the entire subtree under the mismatched node and re-renders it client-side. You pay for SSR twice—once to generate the HTML the user briefly sees, once to throw it away—and the user sees a flash of unstyled or stale content, loses scroll position inside the affected subtree, and gets event handlers attached later than they should be. In React 19 the discard is quieter than the noisy console warning suggests, which makes it easy to ship a page whose SSR is effectively disabled without noticing. The whole point of the diagnosis below is to keep the first client render byte-identical to the server string so the fast path—adopt, do not rebuild—actually fires.
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.
The invariant is narrower than most people assume, and getting the boundary right is what makes the fix obvious. Only the first synchronous render pass on the client is compared against the server string. Everything that happens afterward—effects firing, state updating, event handlers running—is a normal client-side update and can produce any markup it likes. That asymmetry is the entire leverage point: if a value cannot be known consistently on both sides at render time, you do not try to make it consistent, you defer reading it until after the first render, where divergence is allowed by construction. A mismatch is therefore never a rendering bug in the ordinary sense; it is a statement that some value was read one render too early.
It also matters that the comparison is structural, not visual. The framework walks the server DOM and the freshly rendered virtual tree node by node, comparing tag names, attributes, and text-node contents in order. Two outputs that look identical in a browser can still mismatch if the text-node boundaries differ or an attribute is present on one side only. This is why “but it looks the same” is never a valid dismissal—the reconciler is comparing the tree the parser built, not the pixels it painted.
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. Reproducing the failure on purpose is worth the two minutes: it gives you a known-broken baseline, confirms your dev server actually surfaces the warning (React strips hydration diffs from production builds, so a production-only repro will hide the very message you need), and lets you verify the fix flips the console from noisy to silent rather than merely appearing to.
# 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.
new Date() is read at two different instants — server render and hydration.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. Do not stop at the first line of the stack: React 19 collapses the human-readable diff and instead prints a component tree with the diverging node marked, so the actionable information is the element path, not the message text. Copy both quoted values into your notes before you change anything—they are the ground truth you will diff the fix against, and they vanish from the console the moment you reload. - 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. The classification matters because each class has a different fix: time and random values are deferred, browser-API reads are guarded, and nesting errors are corrected in the markup and cannot be papered over. Get the class wrong and you will reach for the wrong tool—deferring a nesting bug, for instance, does nothing because the parser reshapes the tree regardless of when you read the value. - 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. This step is the pivot of the whole workflow: if the trace shows the read firing inside the render function, you have found the culprit; if it fires only from an effect, the mismatch is coming from somewhere else and you have been chasing a red herring. A fast proxy in React is to check whether the value flows throughuseState’s initializer or straight into JSX—an initializer runs on both server and client and is therefore render-time; asetStatecall inside an effect is not. - 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. This bisection saves you from fixing the wrong layer. A local divergence lives inside the component and is fixed by deferral or a guard; a data divergence means the server and client started from different state and is fixed by serializing the payload. Reloading twice matters because some sources—a random ID, say—only differ across requests, so a single reload can look clean by luck while the underlying non-determinism is still there.
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. The reason this works is timing, not cleverness: on the server the effect never runs at all, and on the client it is scheduled to run only after hydrateRoot has finished adopting the DOM. Both sides therefore render the placeholder during the compared pass, the trees line up, and the live value arrives as a follow-up commit that the reconciler treats as an ordinary state change.
// 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 choice of null as the initial state is deliberate: it renders to a stable placeholder that you control, rather than to whatever the live value happened to be, which is exactly what keeps the two first-pass renders equal. Note the placeholder is not free—it means the user sees the dash for one paint before the effect commits. For values that are visible above the fold you may prefer to serialize the value (covered below) so the real content ships in the HTML; for a live clock, a one-frame placeholder is the correct trade because there is no server-stable “true” value to ship. The Vue equivalent uses the same deferral:
<!-- src/Clock.vue — Vue 3.4.x, fixed -->
<script setup>
import { ref, onMounted } from 'vue';
const time = ref(null);
onMounted(() => {
// onMounted is client-only; the server-rendered template shows the dash:
time.value = new Date().toLocaleTimeString();
});
</script>
<template>
<p>Rendered at {{ time ?? '—' }}</p>
</template>
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>
The browser-only case deserves its own pattern because the naive guard makes the mismatch worse, not better. Reading window.matchMedia behind a typeof window !== 'undefined' check returns false-equivalent on the server and the real value on the client, so the two renders diverge exactly the way Date did. The correct shape is to render the server-safe default on the first pass and read the real value in an effect, so both first renders agree:
// src/useIsWide.ts — React 18.x, SSR-safe media query
import { useEffect, useState } from 'react';
export function useIsWide(query = '(min-width: 768px)') {
// Server and first client render both see `false` — markup matches:
const [isWide, setIsWide] = useState(false);
useEffect(() => {
// Browser-only: matchMedia does not exist in Node, so this never runs on the server.
const mql = window.matchMedia(query);
setIsWide(mql.matches); // post-hydration update, no mismatch
const onChange = (e: MediaQueryListEvent) => setIsWide(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return isWide;
}
The trap this avoids is initializing state directly from window.matchMedia(query).matches. That reads the browser API during render, which is fine in a client-only app but throws in Node during SSR and, if guarded to a default, still splits the two renders. Seeding false and correcting it in the effect keeps the first render deterministic across both runtimes.
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} />);
The subtlety in the serialization path is that the state must be embedded into the same HTML response that carried the markup, not fetched separately. If the client fetches the list again it will race the server’s snapshot, and any ordering, pagination, or timestamp difference reappears as a mismatch. Serialize with a JSON-safe encoder—JSON.stringify escaped for </script> sequences, or a library like devalue when the payload contains Date, Map, or undefined—because a payload that JSON.parse chokes on fails silently and drops you back to refetching. The rule is one source of truth per render: whatever the server rendered from must be exactly what the client hydrates from.
How it works under the hood
Understanding the two-pass model makes every fix above predictable rather than superstitious. On the server, Vite’s ssrLoadModule transforms your source, executes it in Node, and renderToString walks the React element tree depth-first, emitting an HTML string with no event listeners and no lifecycle—effects are stripped, because there is no browser to run them in. That string is injected into the page template and sent. On the client, Vite serves the same components as native ESM, and hydrateRoot performs a special render whose job is adoption: instead of creating DOM nodes it walks the existing ones the parser built from the server string, and for each node it renders the corresponding component and compares. If tag, attributes, and text agree, it attaches event listeners to the existing node and moves on—cheap. If they disagree, it bails out of adoption for that subtree and re-creates it from scratch—the expensive path you are trying to avoid.
The comparison is order-sensitive and runs before any effect fires, which is why the timing of a value read is the only thing that matters. React schedules effects via the commit phase, after hydration has walked the whole tree; Vue’s onMounted is queued for the same post-hydration point. So a value read inside the render body participates in the compared pass, and a value read inside an effect does not. The whole family of fixes—useState(null) plus useEffect, ref(null) plus onMounted, serializing the payload—are all just ways of guaranteeing that the value present during the compared pass is identical on both runtimes. There is no framework flag that relaxes the comparison; there is only controlling what the compared render produces.
Verification
Verification is not “the warning is gone”—React 19 can suppress the message while still discarding the subtree, so absence of a warning is necessary but not sufficient. Confirm the fast path actually fired, using four independent signals.
- Reload twice; the console shows no
Hydration failed/Hydration node mismatchwarning. Reload twice rather than once, because a source that only varies across requests (a per-request random ID) can produce a clean single reload by coincidence while remaining broken. - 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. Checking the raw HTML rather than the rendered page is the point—by the time DevTools shows you the DOM, the effect has already run and painted the live value over the placeholder, hiding whether the server ever emitted it. - 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. A re-render flash under throttling is the visual tell that adoption failed and the subtree was rebuilt.
- For data-driven cases, diff
renderToStringoutput against the live DOM by logging both; they should be byte-identical before the first effect fires. A one-off assertion in a test harness that compares the server string todocument.getElementById('app').innerHTMLimmediately afterhydrateRootgives you a regression guard cheaper than any end-to-end check.
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. The mechanism is that React coalesces or splits adjacent text depending on how the JSX is written, and the parser normalizes whitespace in the served HTML; if the two disagree on where one text node ends and the next begins, the reconciler sees a structural difference the eye cannot. Confirm by viewing source and counting the text nodes, not by trusting that the words match. - 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. The root cause is that HTML has a fixed content model the browser enforces during parsing: a block element inside a<p>implicitly closes the paragraph, and a table row without a section gets a<tbody>inserted around it. React never sees this rewrite—it hydrates against the tree it expected to emit—so the fix is always to emit valid markup in the component, never to silence the symptom. 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. It also does not prevent the discard-and-rebuild; it only stops the warning, so a wrapper-level suppression leaves you paying the re-render cost with no console evidence of why. Reserve it for a single leaf element whose divergence is genuinely intended, such as a server-stamped timestamp you want shown verbatim.- 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. This one bites hardest in production, where the Node host runs in UTC and the developer’s laptop runs in a local zone, so the bug never reproduces locally. Pass{ timeZone: 'UTC', locale: 'en-US' }explicitly on both sides, or serialize the pre-formatted string from the server so the client renders a value it never computed.
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.