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.
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, , or data-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
You've built at least one Nuxt page with and know roughly what server-side rendering means (the server sends back real HTML instead of an empty ). 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
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:
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:
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: 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/useFetch call and every useState, embedded in the page as a 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-if branching 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.
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:
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.
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. is auto-imported and does exactly that:
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.
