Hydration mismatch in Nuxt and Next.js: 7 causes and fixes
Hydration mismatch in Nuxt and Next.js: seven causes and how to find each one. Plus an experiment on Vue 3.5 and React 19 showing what the frameworks actually do with a mismatched DOM, and which mismatches stay silent in production.
A hydration mismatch happens when the HTML rendered on the server differs from what the framework produces on its first render in the browser. Nearly every case comes down to one of seven causes: dates and time zones, random values and IDs, rendering based on screen width, invalid HTML nesting, CDN caching, browser extensions, and typeof window branches. The red console error is not the worst outcome. I tested what Vue 3.5.44 and React 19.3.1 actually do with a mismatched DOM: a mismatched class in Vue and mismatched attributes in React stay in the DOM with their server values, and in production nothing reports them.
What hydration is and why a hydration error matters
With SSR, the server runs your components and sends ready-made HTML. In the browser, the framework runs the same components again, matches each one to an existing DOM node and attaches event listeners. That step is hydration. It has one requirement: the first client render must produce exactly the same markup as the server.
The React docs treat mismatches as bugs: at best they slow the page down, at worst event handlers end up attached to the wrong elements. The Nuxt docs add more consequences: slower time to interactive, flashing content and layout shifts, and search engines possibly indexing something other than what users see.
The error text depends on the version:
Framework | What the error looks like |
|---|---|
React 18 | several messages, e.g. |
React 19 | a single |
Vue 3.4+ | |
What Vue and React do with a mismatched DOM: an experiment
The docs describe recovery only in general terms: Vue tries to adjust the DOM to the client state, React recovers from some errors. To see what actually stays on the page, I ran an experiment.
Experiment method
- Versions: Vue 3.5.44, React and react-dom 19.3.1, jsdom 30.1.3, Node.js 22.23.4.
- One component is rendered on the server with
renderToStringandside = 'server', then hydrated in jsdom withside = 'client'. The paragraph text,class,style, thetitleattribute and, in Vue, the element type (spanon the server,emon the client) differ. Next to them sit a static paragraph with no differences and a counter button. - Before hydration I keep references to the DOM nodes; afterwards I check whether the same node is still in the document and what value it holds. Then I click the button to check event handlers.
- Every scenario runs in both development and production builds (
NODE_ENV=production). - jsdom is not a browser, and no timing is measured here. But the decision which node to keep, which to recreate and which value to write is made by the framework's JavaScript, not by the browser, so jsdom is fine for this question. Hydration speed in a real browser is a separate topic.
Results
What differs | Vue 3.5.44 | React 19.3.1 |
|---|---|---|
Text | replaced with the client value, same node | without |
| keeps the server value | keeps the server value |
| keeps the server value until the component re-renders | – |
Dynamic | fixed during hydration | keeps the server value |
Element type | server node removed, new one created, siblings untouched | – |
Visible in production | one | text mismatch: code 418 via |
Vue behaves the same in development and production. In React development, an attribute mismatch goes to console.error with a note that it won't be patched up; in production it goes nowhere, neither to the console nor to onRecoverableError.
Why Vue fixes title but not class
For class, style and attributes, Vue compares values and prints a warning that the source code labels as check-only, meaning the DOM is not fixed. Separately, Vue patches properties that the template compiler marked as dynamic props. A :title binding lands in that list; :class and :style are compiled with different flags and do not.
style was corrected in the experiment after the button click: the component re-rendered, and on update Vue re-applies every property of a style object. class does not change on re-render until its value changes.
What follows
- In React, the cost of a single text mismatch depends on the nearest
<Suspense>boundary. Without one, the whole root is recreated, including nodes the user may already have interacted with before JS loaded. - Monitoring that listens to
console.errorwill miss React 19 hydration errors.reportErrordelivers the error to the globalerrorevent handler, like an uncaught exception.
Seven causes of hydration mismatch
1. Dates, times and time zones
Mechanism. new Date(), toLocaleString() and Intl.DateTimeFormat without explicit options use the environment's time zone and locale. The server and the user are often in different zones, and the Vue docs state plainly that the server cannot reliably know the user's time zone.
Not only the hour changes; the date can too. My calculation in Node.js 22.23.4 for the instant 2026-10-28T22:30:00Z with the en-US locale:
Where it renders | Result |
|---|---|
Server in UTC | 10/28/26, 10:30 PM |
User in Los Angeles | 10/28/26, 3:30 PM |
User in Tokyo | 10/29/26, 7:30 AM |
There is a second, less obvious layer: Intl output depends on the ICU library and CLDR data version inside the engine. ICU 72 started putting a narrow no-break space (U+202F) instead of a regular space between the time and AM/PM in English. Node.js picked this up in 19.1, and user reports included hydration errors. V8 and Firefox eventually added a replacement back to a regular space to avoid breaking sites. The general lesson: identical code with an identical locale produces different bytes on different runtime versions, and you cannot see the difference by eye.
How to find it. In development, the message shows the server and client strings. If they look identical, compare character codes:
[...str].map((c) => c.charCodeAt(0).toString(16))How to fix it.
- Pass
timeZoneandlocaleexplicitly toIntl.DateTimeFormaton both server and client. This removes the dependency on the environment's time zone, but not on the ICU version; where every character matters, format on the client. - Convert to the user's local time only on the client, after mount, as the Vue docs recommend.
- In Nuxt 3.17+, use
<NuxtTime>, which renders the same result on server and client. - If the difference is unavoidable, such as "5 minutes ago", suppress it locally (see below).
2. Random values and generated IDs
Mechanism. Math.random(), crypto.randomUUID() and Date.now() in render are evaluated twice, on the server and in the browser, and produce different values. This most often surfaces in IDs used for label for and aria-*.
A special case is a module-level counter like let nextId = 0. On the server, modules are initialized once at startup and live across requests, so the server counter keeps growing from request to request while the client counter starts at zero every time.
How to find it. A message about an id, for or aria-* attribute whose values look generated.
How to fix it.
- For IDs, use
useId(): it exists in Vue since 3.5 and in React, and Nuxt has its ownuseIdsince 3.10 that requires a component with a single root element. - For random content, generate the value on the server and pass it to the client in state. The Vue docs suggest a seeded generator with the seed passed along with the state. In Nuxt,
useStateis enough:
<script setup lang="ts">
const tip = useState('tip', () => Math.floor(Math.random() * tips.length))
</script>3. Rendering based on screen width and browser data
Mechanism. The server does not know the window width, the system theme, the contents of localStorage, or cookies set by client scripts. Code like v-if="window.innerWidth > 768" or reading the theme from localStorage in setup produces one tree on the server and another in the browser. React specifically lists window.matchMedia in render among common causes.
This is where the experiment matters. If width only affects a class (:class="{ compact: isMobile }"), Vue in production keeps the server class and says nothing. If width affects structure (v-if), Vue recreates nodes and the user sees a flash.
How to find it. Open the page in a narrow and a wide window: the mismatch appears at only one of the sizes.
How to fix it.
- Handle layout with CSS media queries, not JS. This is the first recommendation in the Nuxt docs too.
- Store the theme in a cookie instead of
localStorage: the server can read cookies, and in Nuxt you get it viauseCookie. - If logic really needs the width, tell the server which width to render for. VueUse has an
ssrWidthoption onuseMediaQueryandprovideSSRWidthfor the whole app. - Two-pass rendering: show a default variant on the server and during hydration, switch after mount. React warns that this slows hydration because components render twice.
4. Invalid HTML nesting
Mechanism. The server sends HTML as a string, and the browser builds a tree from it following the HTML spec's parsing rules, silently fixing invalid nesting. The framework expects one structure and finds another. Here is how the jsdom parser (parse5 implements the WHATWG spec) rebuilds typical mistakes:
Server sent | Browser built |
|---|---|
| |
| |
| |
| |
Next.js lists the same cases: <p> in <p>, <div> and lists in <p>, interactive elements inside each other. One way this happens is CMS HTML with its own paragraphs injected into a component that wraps it in a <p>.
How to find it. Compare View Page Source (the original HTML) with the Elements panel in DevTools. If nodes have moved or a tbody appeared that is not in the source, the parser is the cause. The message usually reports a node mismatch or a different number of children.
How to fix it. Fix the markup. Suppressing this kind of mismatch makes no sense: the structure differs, not a value.
5. CDN caching and stale HTML
Mechanism. Here the render is correct, but something between the server and the browser changes the HTML or swaps it for another. There are three variants.
- A proxy rewrites HTML. Next.js cites Cloudflare Auto Minify as an example. That feature was removed on August 5, 2024, but the class of problem remains: any HTML optimization at the CDN or proxy changes what hydration receives.
- The cache ignores something the render depends on. If the server renders by cookie (theme, city, A/B variant) and the CDN caches one HTML per URL, a user gets someone else's variant while their browser renders their own. Caches distinguish responses by the headers listed in
Varyand by the cache key configured on the CDN. Everything that affects rendering must be part of that key. - HTML and data from different moments or builds. For prerendered pages, Nuxt extracts data into a separate payload file by default. If HTML and payload are cached with different lifetimes, they can drift apart. In Next.js, the
deploymentIddocs describe a similar situation during rolling deployments: users receive a mix of old and new assets.
How to find it. The error appears only in production, only for some users, or only after a deploy. Request the page bypassing the cache and through the CDN, then compare the HTML. Check the response headers for cache hits.
How to fix it.
- Do not server-render personal content on cached pages; move it to the client.
- If personal content must be in the HTML, include the relevant cookies or headers in the cache key.
- Serve HTML and data with consistent lifetimes. In Next.js, set
deploymentId. - Disable CDN features that rewrite HTML.
6. Browser extensions and the browser itself
Mechanism. Browser extensions can modify the DOM before your JavaScript loads. Next.js and React's own error text list this as a cause. React 19 is more tolerant: unexpected tags in <head> and <body> are skipped during hydration, and stylesheets inserted by extensions are kept even when React re-renders the whole document. In Vue and Nuxt, hydration happens only inside the app's root container (#__nuxt by default in Nuxt), so insertions into body outside it do not affect hydration.
The browser itself is a separate case. Safari on iOS detects phone numbers, addresses and emails in text and turns them into links, and Next.js names this as a source of mismatches. It matters for any site with a phone number in the header or footer.
How to find it. The error does not reproduce in a private window without extensions, or reproduces only on an iPhone.
How to fix it. You do not control extensions, so first confirm they are the cause and avoid hunting for a bug in your code. In React 19, tags inserted into <head> and <body> no longer break hydration. For iOS, Next.js recommends a meta tag; marking up phone numbers as tel: links from the start also helps:
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />7. typeof window and import.meta.client branches
Mechanism. A if (typeof window !== 'undefined') or if (import.meta.client) branch directly in render produces one tree on the server and another in the browser by definition. React lists this cause first in its error text, and the Nuxt docs show it in an example of initializing a library in setup. The issue is timing: on the client the condition is already true during hydration, while the DOM is still the server one.
How to find it. Search the project for typeof window, typeof document, process.client and import.meta.client outside mount hooks.
How to fix it.
- Move browser code into
onMountedin Vue oruseEffectin React. These hooks do not run on the server and have not run yet during hydration. - Wrap browser-only components in
<ClientOnly>in Nuxt or load them viadynamic(..., { ssr: false })in Next.js. The current React docs suggestuse(browser())for components that should render only in the browser, instead of waiting for an effect. - Give the server placeholder the same size as the future component, or you trade a hydration error for a layout shift.
<ClientOnly>has a#fallbackslot for this:
<ClientOnly>
<PriceChart />
<template #fallback>
<div class="chart-placeholder" />
</template>
</ClientOnly>How to find hydration mismatches in production
Development builds give details; production stays almost silent. What you can do:
- Plain Vue. The
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__build flag (Vue 3.4+, off by default) enables detailed messages in production. It adds code to the bundle, so enable it while debugging. In Vite, set it viadefine. - Nuxt. Since 3.16 the
debugoption accepts an object with ahydrationkey. I checked the@nuxt/schema4.6.3 source:debug: { hydration: true }ordebug: trueturns on the same flag. The Nuxt Hints module shows server and client HTML side by side in DevTools with the differences highlighted. - React and Next.js. Pass
onRecoverableErrortohydrateRootand send errors to monitoring. If the framework creates the root, as in Next.js, listen for the globalerrorevent: by default React 19 reports throughreportError. Attribute mismatches are invisible in production; you can only catch them in development. - Automated tests. An E2E test can listen to the browser console and fail on a hydration message. Run it against a development build, or React attribute mismatches and Vue details disappear; and for React, listen to the
errorevent as well as the console. - Manually. Disable JavaScript, save the page HTML, re-enable JavaScript and compare it with the DOM after load.
How to enable the details while debugging:
// nuxt.config.ts
export default defineNuxtConfig({
debug: { hydration: true },
})
// vite.config.ts, Vue without Nuxt
export default defineConfig({
define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' },
})When it is fine to suppress a mismatch
Sometimes a difference is unavoidable: "updated 3 minutes ago", a random tip of the day. Both frameworks offer local suppression, but it behaves differently.
| Vue 3.5+: | React: |
|---|---|---|
Scope | element; can be limited to | element, one level deep |
Text on the page | still replaced with the client value, only the warning is silenced | not patched, stays the server value until the next render |
Key takeaways
- A hydration mismatch almost always comes down to one of seven causes: time, randomness, browser data, invalid nesting, caching, extensions, environment branches.
- Vue 3.5 fixes text and structure but keeps the server
class; React 19 never fixes attributes, and a single text mismatch without<Suspense>recreates the whole root. - In production both Vue and React report almost nothing: enable
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__or Nuxt'sdebug.hydrationwhile debugging, and in React catchonRecoverableErroror the globalerrorevent. - Format dates with explicit
timeZoneandlocale, get IDs fromuseId, and do width-based layout in CSS. - On cached pages, don't server-render anything personal, or make it part of the cache key.
data-allow-mismatchandsuppressHydrationWarningare only for unavoidable differences in values, not in structure.
Sources
- 01Server-Side Rendering (SSR)Vue.js
- 02runtime-core/src/hydration.tsGitHub, vuejs/corechecked against the 3.5.44 build
- 03hydrateRootreact.dev
- 04React v19react.dev · 2024
- 05Text content does not match server-rendered HTMLNext.js
- 06Nuxt and HydrationNuxt