Skip to content

14 min read

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. Text content does not match server-rendered HTML

React 19

a single Hydration failed because the server rendered HTML didn't match the client with a server/client diff

Vue 3.4+

Hydration text content mismatch, Hydration class mismatch and so on, pointing to the DOM node; text, class, style and dynamic attributes are checked

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 renderToString and side = 'server', then hydrated in jsdom with side = 'client'. The paragraph text, class, style, the title attribute and, in Vue, the element type (span on the server, em on 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 <Suspense> the whole root is recreated; inside <Suspense> only the boundary

class

keeps the server value

keeps the server value

style

keeps the server value until the component re-renders

–

Dynamic title attribute

fixed during hydration

keeps the server value

Element type

server node removed, new one created, siblings untouched

–

Visible in production

one Hydration completed but contains mismatches. line, no element

text mismatch: code 418 via reportError; attributes: nothing

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.error will miss React 19 hydration errors. reportError delivers the error to the global error event 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:

JavaScript
[...str].map((c) => c.charCodeAt(0).toString(16))

How to fix it.

  • Pass timeZone and locale explicitly to Intl.DateTimeFormat on 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 own useId since 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, useState is enough:
Vue
<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 via useCookie.
  • If logic really needs the width, tell the server which width to render for. VueUse has an ssrWidth option on useMediaQuery and provideSSRWidth for 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

<p><div>hi</div></p>

<p></p><div>hi</div><p></p>

<table><tr>…</tr></table>

<table><tbody><tr>…</tr></tbody></table>

<a>x<a>y</a></a>

<a>x</a><a>y</a>

<p>one<p>two</p></p>

<p>one</p><p>two</p><p></p>

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 Vary and 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 deploymentId docs 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:

HTML
<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 onMounted in Vue or useEffect in 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 via dynamic(..., { ssr: false }) in Next.js. The current React docs suggest use(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 #fallback slot for this:
Vue
<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 via define.
  • Nuxt. Since 3.16 the debug option accepts an object with a hydration key. I checked the @nuxt/schema 4.6.3 source: debug: { hydration: true } or debug: true turns 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 onRecoverableError to hydrateRoot and send errors to monitoring. If the framework creates the root, as in Next.js, listen for the global error event: by default React 19 reports through reportError. 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 error event 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:

TypeScript
// 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+: data-allow-mismatch

React: suppressHydrationWarning

Scope

element; can be limited to text, children, class, style, attribute

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's debug.hydration while debugging, and in React catch onRecoverableError or the global error event.
  • Format dates with explicit timeZone and locale, get IDs from useId, 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-mismatch and suppressHydrationWarning are only for unavoidable differences in values, not in structure.

Sources

  1. 01Server-Side Rendering (SSR)Vue.js
  2. 02runtime-core/src/hydration.tsGitHub, vuejs/corechecked against the 3.5.44 build
  3. 03hydrateRootreact.dev
  4. 04React v19react.dev · 2024
  5. 05Text content does not match server-rendered HTMLNext.js
  6. 06Nuxt and HydrationNuxt