Your Nuxt page looks perfect. "View Source" shows clean, fully-rendered HTML — the hero text, the product price, the footer, all there before a single line of JavaScript ran. Then the client bundle finishes loading, and the console lights up: [Vue warn]: Hydration text mismatch. Sometimes it's cosmetic — a number flickers and settles. Sometimes it's worse: a button the user already clicked stops responding, because Vue just tore out the DOM node it was attached to and built a new one.
This is a hydration mismatch, and it's arguably the most Nuxt-specific bug you'll ever debug. It has nothing to do with your logic being wrong in the way a typo is wrong — your component can be perfectly correct JavaScript and still cause one, because the bug isn't in what you wrote, it's in the fact that Nuxt runs what you wrote twice, in two different places, and bets your app's interactivity on both runs agreeing.
This article is written against Nuxt 4.x (verified against the v4.5 release line, August 2026), using the Composition API, auto-imports, and the app/ directory convention Nuxt 4 defaults to. Everything here also applies to Nuxt 3's compatibilityVersion: 4 mode.
What you'll learn
By the end of this article you'll be able to:
- Explain exactly what "hydration" means in Nuxt and why a mismatch happens
- Recognize the handful of code patterns that reliably cause one
- Pick the right fix —
onMounted,<ClientOnly>, ordata-allow-mismatch— for each situation - Read a hydration warning and know which line of your code to blame
- Avoid the "fix" that looks reasonable but guarantees a mismatch every time
Who this is for
You've built at least one Nuxt page with <script setup> and know roughly what server-side rendering means (the server sends back real HTML instead of an empty <div id="app">). You don't need prior SSR debugging experience — that's the point of this article.
Table of contents
- The problem: a page that's "correct" and still breaks
- The mental model: two renders, one DOM
- Fixing it, stage by stage
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: a page that's "correct" and still breaks
Say you're building a "tip of the day" widget. It's a plain computed value, no fetch, no state management — about as simple as a Vue component gets:
<script setup>
const TIPS = [
"Use useAsyncData for anything that fetches.",
"Auto-imports save you the import line, not the thinking.",
"Nitro is just Node under the hood.",
]
const tip = TIPS[Math.floor(Math.random() * TIPS.length)]
</script>
<template>
<p>Tip of the day: {{ tip }}</p>
</template>
Nothing here looks wrong. It compiles, it runs, npm run dev shows a tip. But open the browser console and you'll see something like:
[Vue warn]: Hydration text mismatch:
- Server rendered: Tip of the day: Nitro is just Node under the hood.
- Client rendered: Tip of the day: Use useAsyncData for anything that fetches.
Nothing crashed. The page still works. But the text the user saw for a split second — the one baked into the HTML the server sent — silently got replaced by a different one the instant the JavaScript took over. If that "tip" were a price, a username, or which item was in stock, this wouldn't be a curiosity, it would be a bug report.
The same failure mode shows up with new Date(), with window.innerWidth, with anything read from localStorage inside the component's render path. The common thread: the value depends on where the code runs, and Nuxt runs your component in two different places.
The mental model: two renders, one DOM
The mental model: Nuxt doesn't render your app once — it renders the same component tree twice, in two different environments, and then asks the second render to adopt the DOM the first render already produced, instead of rebuilding it from scratch.
Here's the sequence for a single page request:
- A request hits your server. Nitro runs your Vue app in Node — no browser, no DOM — and walks your components to produce a plain HTML string, plus a serialized payload: the results of every
useAsyncData/useFetchcall and everyuseState, embedded in the page as a<script id="__NUXT_DATA__">block. - The browser receives that HTML and paints it immediately. This is the entire point of SSR — the user sees real content before a single byte of your JavaScript bundle has downloaded.
- The client bundle downloads and boots the same Vue app, client-side. But instead of creating new DOM nodes the way a client-only SPA would, it runs in hydration mode: it walks the existing DOM the server produced, node by node, and attaches reactivity and event listeners to what's already there, reading the payload from step 1 so it doesn't have to re-fetch data the server already fetched.
Hydration is a reconciliation, not a second render from scratch — and reconciliation assumes the two renders agree. When they do, hydration is invisible: the DOM stays exactly as the server drew it, listeners attach, the page becomes interactive. When they don't, Vue has two options depending on how badly they disagree:
-
A text or attribute mismatch (a
{{ tip }}that resolved differently, a class that differs): Vue patches just that value in place and — in development only — logs a warning. Production builds do this silently, which is why a mismatch can ship for weeks before anyone notices. -
A structural mismatch (a different tag, a different number of children — the kind you get from
v-ifbranching differently on each side): Vue can't patch that in place. It throws away the mismatched subtree and re-renders it entirely client-side. That's real, visible re-work, and if a user had already interacted with something inside that subtree, the element they clicked no longer exists.
The payload exists specifically so that data is safe across hydration — useAsyncData, useFetch, and useState all serialize their results, so the client reads the exact value the server used instead of recomputing it. (If you've read the earlier episode on useAsyncData keys and dedupe, this is the same payload that makes dedupe possible — it's doing double duty.) The danger is everything outside that mechanism: any value your template reads that isn't backed by useState/useAsyncData and isn't guaranteed identical on both sides — Math.random(), Date.now(), window, navigator, localStorage — is a mismatch waiting to happen, because nothing carries it across the server→client boundary for you.
Fixing it, stage by stage
Stage 1: defer the value with onMounted
The tip-of-the-day bug and the "current time" bug are the same shape: a value that's legitimately allowed to differ per visitor, rendered directly during setup. The fix is to give the template a stable, server-safe default, and only fill in the real value once you're certain you're client-side:
<script setup>
import { ref, onMounted } from "vue"
const tip = ref(null)
onMounted(() => {
const TIPS = ["Use useAsyncData for anything that fetches.", "…"]
tip.value = TIPS[Math.floor(Math.random() * TIPS.length)]
})
</script>
<template>
<p>Tip of the day: {{ tip ?? "Loading…" }}</p>
</template>
Key concept: onMounted runs only after hydration has already completed successfully. Anything it writes is a normal, client-only reactive update — Vue never has to reconcile it against server HTML, because by the time it runs, hydration is already done.
Stage 2: skip SSR entirely with <ClientOnly>
Some content isn't "slightly different" between server and client — it can't exist on the server at all. A chart that measures its container's pixel width, a widget that reads localStorage, a third-party embed that expects window. For those, don't try to make the server render something — tell Nuxt not to render it there in the first place. <ClientOnly> is auto-imported and does exactly that:
<template>
<ClientOnly>
<UserLocalClock />
<template #fallback>
<span class="clock-placeholder">--:--</span>
</template>
</ClientOnly>
</template>
The default slot never runs on the server. The #fallback slot renders there instead (useful for reserving layout space so nothing jumps), and the moment the component mounts client-side, Nuxt swaps the fallback for the real content — created fresh, never hydrated.
Key concept: <ClientOnly> doesn't resolve a mismatch — it removes the possibility of one, because nothing inside it is ever compared between two renders. There's only ever one render, on the client.
Stage 3: the branch that looks like a fix but isn't
It's tempting to reach for Nuxt's environment flags — import.meta.server / import.meta.client (the modern replacement for the older process.server / process.client) — and branch your template directly on them:
<!-- Don't do this -->
<template>
<div v-if="import.meta.client">Client-rendered content</div>
<div v-else>Server-rendered content</div>
</template>
This guarantees a structural mismatch, every single time. On the server, import.meta.server is true, so the server emits the <div> from the v-else branch. On the client, during hydration, import.meta.client is true, so Vue's hydration walk expects the v-if branch — a different <div> than the one actually sitting in the DOM. Vue can't reconcile two different branches in place; it discards and re-renders. import.meta.client/.server are genuinely useful for deciding what code runs (skip a browser-only import on the server, skip a Node-only one on the client) — they're the wrong tool for deciding what a hydrated template renders, because that decision has to be identical in both places by definition.
Stage 4: when a mismatch is real, expected, and fine — data-allow-mismatch
Occasionally you'll have a value that will always differ by design — a relative timestamp ("posted 3 minutes ago") that keeps ticking, for instance — and you've already accepted that as correct behavior rather than a bug. Vue 3.5 added an attribute for exactly this: data-allow-mismatch silences the hydration warning for a specific element, scoped to the kind of mismatch you name (text, children, class, style, or attribute):
<time data-allow-mismatch="text">{{ relativeTime }}</time>
This only suppresses the console warning — it does nothing to make the values agree. Reach for it after you've decided the mismatch is cosmetic and harmless, never as a first response to a warning you haven't diagnosed yet.
Edge cases and gotchas
-
Invalid HTML nesting causes mismatches with no logic bug at all. A
<div>nested inside a<p>, or malformed<table>markup, gets silently corrected by the browser's HTML parser while it parses the server's HTML — the browser closes the<p>early, restructuring the tree Vue expected to hydrate onto. The fix is markup hygiene, not JavaScript: keep nesting valid per the HTML content model. -
Browser extensions mutate the DOM before your JS runs. Grammarly, password managers, and dark-mode extensions routinely inject attributes into the page before hydration starts. These aren't your bug and can't be reliably prevented;
data-allow-mismatch="attribute"on the affected element is the pragmatic escape valve once you've confirmed the source. -
Server and client timezones differ. A server running in UTC formatting a date directly in a template will disagree with a client in the visitor's local timezone. Same class of bug as
Date.now()— same fix: compute the display string inonMounted. -
A
refseeded from a browser API at module or setup scope.const isWide = ref(window.innerWidth > 768)throws on the server (there is nowindow) or, if guarded, still needs a server-safe default and a client-side correction — the sameonMountedpattern applies. -
Shared server state is a related but different bug. If your mismatch is about the wrong user's data appearing rather than a timing difference, that's the cross-request state leak, not a hydration mismatch — see the earlier episode on
useStatevs. a plainrefif that's the symptom you're chasing.
Best practices
- Ask one question of every render-affecting expression: given the same props and payload, does this produce the exact same output on the server and the client? If the honest answer is "no," it doesn't belong directly in the template.
-
Default first, correct in
onMounted. Any value that's allowed to differ per visitor gets a server-safe placeholder and a client-side update after mount — never a direct read of a browser API during setup. -
Reach for
<ClientOnly>for whole widgets, not individual values. If an entire component only makes sense in a browser (canvas-sized charts,window-dependent libraries), don't fight it into an SSR-safe shape — skip SSR for it. -
Never branch a hydrated template's markup on
import.meta.client/.server. Use those flags to decide what code runs, not what a hydrated component renders. - Lint your markup. Invalid HTML nesting is an easy, boring source of mismatches that a markup or accessibility linter catches before it ever reaches a browser.
-
Test against a production build, not just
nuxt dev. Runnuxt build && nuxt previewbefore shipping something that touches SSR — dev's warnings are the same, but dev's timing can mask issues that show up under real hydration.
FAQ
Does a hydration mismatch crash my app?
No — Vue reconciles it either way. A text/attribute mismatch is patched in place; a structural one is discarded and re-rendered client-side. The app keeps working, but a structural mismatch means real extra work and a possible flash or loss of state in that subtree.
Why does the warning only appear in development?
Vue's hydration mismatch console warning is a development-only diagnostic. In a production build, the same reconciliation happens, but silently — which is exactly why these bugs can ship unnoticed for a long time. Always sanity-check SSR-sensitive pages against a nuxt preview build, not just dev.
Is <ClientOnly> the same thing as checking import.meta.client?
No. import.meta.client is a compile-time flag that decides which lines of code are included in which bundle — it's a build-time tool. <ClientOnly> is a runtime component that skips server rendering for its slot content and mounts it fresh in the browser. Using the flag to branch a hydrated template's markup causes the exact mismatch this article is about; <ClientOnly> avoids it by never hydrating that content at all.
Does useState prevent hydration mismatches?
It prevents the specific class caused by state disagreeing between server and client, because its value is serialized into the payload and read identically on both sides. It doesn't protect a value your template computes independently of useState — Math.random() inside a <script setup> block is still a mismatch even if an unrelated useState call exists elsewhere in the same component.
Can a mismatch happen even when my code is completely correct?
Yes. Third-party scripts and browser extensions can alter the DOM before your app hydrates, and that's outside your code's control. data-allow-mismatch on the specific affected attribute is the accepted mitigation once you've confirmed that's the cause.
Cheat sheet
| Situation | Symptom | Fix |
|---|---|---|
Math.random() / Date.now() read during setup or render |
Text mismatch warning, value flickers on load | Default to null/placeholder, set the real value in onMounted
|
Reading window, navigator, localStorage in the template's data path |
Throws on server, or mismatches if guarded naively |
ref(defaultValue) + onMounted to correct it |
| Whole widget only makes sense client-side (canvas size, browser-only lib) | Mismatch or server crash | Wrap it in <ClientOnly> with a #fallback
|
v-if="import.meta.client" branching a hydrated template |
Structural mismatch, guaranteed, every load | Don't branch markup on the flag — use <ClientOnly>/onMounted instead |
| Relative time / genuinely-expected drift you've accepted | Warning you don't want to see |
data-allow-mismatch="text" (Vue 3.5+) — after you've confirmed it's harmless |
<div> inside <p>, broken table markup |
Mismatch with no obvious cause in your JS | Fix the HTML nesting; lint markup |
| Grammarly / extensions injecting attributes | Attribute mismatch you can't reproduce locally without the extension |
data-allow-mismatch="attribute" on the affected element |
<script setup>
import { ref, onMounted } from "vue"
// Server-safe default — identical on both renders.
const clientValue = ref(null)
onMounted(() => {
// Runs only after hydration succeeds — safe to diverge here.
clientValue.value = computeSomethingClientOnly()
})
</script>
<template>
<p>{{ clientValue ?? "Loading…" }}</p>
<!-- For whole subtrees that can never run on the server: -->
<ClientOnly>
<BrowserOnlyWidget />
<template #fallback><span>Loading…</span></template>
</ClientOnly>
</template>
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Key takeaways
- A hydration mismatch happens because Nuxt renders your app twice — once on the server, once in the browser — and hydration assumes, without verifying up front, that both renders agree.
- The near-universal cause is a render-affecting value that isn't guaranteed identical on both sides:
Math.random(),Date.now(), or any direct read of a browser-only API. -
onMountedfixes values that are allowed to differ once hydration is already done;<ClientOnly>fixes whole subtrees that can never run on the server;data-allow-mismatchonly silences a warning you've already confirmed is harmless. - Never branch a hydrated template's markup on
import.meta.client/.server— that's the one "fix" that reliably causes the exact bug it's trying to solve.
🧠 Test yourself
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
One more render to get right
That tip-of-the-day widget from the top of this article has an honest fix now — a ref that starts null and fills in after mount, instead of a Math.random() call sitting directly in the render path. The bug was never really about randomness; it was about where the randomness ran, and Nuxt was always going to run it twice.
What's the strangest hydration mismatch you've had to track down — a third-party script, a timezone, something stranger? Drop it in the comments; there's a decent chance someone else's next [Vue warn] is exactly the one you already solved.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)