[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-nuxt-weekly-hydration-mismatch":44,"search-suggestions":60,"quiz-article-nuxt-weekly-hydration-mismatch":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0b07e-f719-76d9-8da4-de3206a17c01","nuxt-weekly-hydration-mismatch","PRACTICE_QUIZ","Nuxt Hydration Mismatch","Test what you learned about why Nuxt renders twice, why the two renders can disagree, and which fix — onMounted, ClientOnly, or data-allow-mismatch — belongs to which situation.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},9,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,96,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",103,{"slug":66,"name":67,"articles":68},"javascript","Javascript",87,{"slug":70,"name":71,"articles":72},"frontend","Frontend",71,{"slug":74,"name":75,"articles":76},"css","Css",34,{"slug":78,"name":79,"articles":80},"tutorial","Tutorial",32,{"slug":82,"name":83,"articles":84},"typescript","Typescript",15,{"slug":86,"name":87,"articles":88},"react","React",13,{"slug":90,"name":91,"articles":92},"performance","Performance",12,{"slug":94,"name":95,"articles":51},"browser","Browser",{"slug":97,"name":98,"articles":51},"node","Node",{"slug":100,"name":101,"articles":102},"html","Html",6,{"slug":104,"name":105,"articles":102},"grammar","Grammar",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":111,"publishedAt":112,"viewCount":113,"likeCount":19,"commentCount":19,"author":114,"vertical":119,"topic":120,"tags":123,"_count":130,"playground":132,"body":134,"bodyMd":463,"seo":464,"translationGroupId":466,"series":467,"podcastUrl":52,"verticalId":5,"thread":479,"assessments":481,"translations":484,"quiz":486},"01a0b07e-f66a-736f-a8f4-3c87da225d8f","Nuxt Hydration Mismatch: Why It Happens and How to Fix It","A Nuxt hydration mismatch happens when the server's HTML disagrees with the client's first render. Learn why, and the fixes that actually work.","\u002Fmedia\u002Fcovers\u002Fnuxt-weekly-hydration-mismatch.png",14,"2026-09-20T16:26:05.191Z",132,{"id":115,"name":116,"username":117,"avatarUrl":52,"headline":118},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":121,"name":122},"nuxt","Nuxt",[124,125,128,129],{"slug":121,"name":122,"color":52},{"slug":126,"name":127,"color":52},"ssr","SSR",{"slug":66,"name":67,"color":52},{"slug":78,"name":79,"color":52},{"assessments":131},1,{"slug":46,"title":133},"Nuxt hydration mismatch — interactive simulation",{"blocks":135,"version":131},[136,140,143,146,151,154,163,166,169,172,183,187,190,196,199,204,207,210,213,216,219,225,228,233,236,239,243,246,250,253,257,260,264,267,270,274,277,281,284,288,291,295,298,301,309,312,321,324,327,330,333,336,340,343,347,350,353,356,359,395,399,402,405,408,411,414,417,424,427,430,433,436,439,442,445,448,451,454,457],{"id":137,"html":138,"type":139},"b1","\u003Cp>Your Nuxt page looks perfect. &quot;View Source&quot; 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: \u003Ccode>[Vue warn]: Hydration text mismatch\u003C\u002Fcode>. Sometimes it&#39;s cosmetic — a number flickers and settles. Sometimes it&#39;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.\u003C\u002Fp>","paragraph",{"id":141,"html":142,"type":139},"b2","\u003Cp>This is a hydration mismatch, and it&#39;s arguably the most \u003Cem>Nuxt-specific\u003C\u002Fem> bug you&#39;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&#39;t in what you wrote, it&#39;s in the fact that Nuxt runs what you wrote \u003Cstrong>twice, in two different places\u003C\u002Fstrong>, and bets your app&#39;s interactivity on both runs agreeing.\u003C\u002Fp>",{"id":144,"html":145,"type":139},"b3","\u003Cp>This article is written against \u003Cstrong>Nuxt 4.x\u003C\u002Fstrong> (verified against the v4.5 release line, August 2026), using the Composition API, auto-imports, and the \u003Ccode>app\u002F\u003C\u002Fcode> directory convention Nuxt 4 defaults to. Everything here also applies to Nuxt 3&#39;s \u003Ccode>compatibilityVersion: 4\u003C\u002Fcode> mode.\u003C\u002Fp>",{"id":147,"html":148,"text":149,"type":150,"level":31},"b4","What you&#39;ll learn","What you'll learn","heading",{"id":152,"html":153,"type":139},"b5","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":155,"type":156,"items":157,"ordered":18},"b6","list",[158,159,160,161,162],"Explain exactly what &quot;hydration&quot; means in Nuxt and why a mismatch happens","Recognize the handful of code patterns that reliably cause one","Pick the right fix — \u003Ccode>onMounted\u003C\u002Fcode>, \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode>, or \u003Ccode>data-allow-mismatch\u003C\u002Fcode> — for each situation","Read a hydration warning and know which line of your code to blame","Avoid the &quot;fix&quot; that looks reasonable but guarantees a mismatch every time",{"id":164,"html":165,"text":165,"type":150,"level":31},"b7","Who this is for",{"id":167,"html":168,"type":139},"b8","\u003Cp>You&#39;ve built at least one Nuxt page with \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> and know roughly what server-side rendering means (the server sends back real HTML instead of an empty \u003Ccode>&lt;div id=&quot;app&quot;&gt;\u003C\u002Fcode>). You don&#39;t need prior SSR debugging experience — that&#39;s the point of this article.\u003C\u002Fp>",{"id":170,"html":171,"text":171,"type":150,"level":31},"b9","Table of contents",{"id":173,"type":156,"items":174,"ordered":18},"b10",[175,176,177,178,179,180,181,182],"\u003Ca href=\"#the-problem-a-page-thats-correct-and-still-breaks\">The problem: a page that&#39;s &quot;correct&quot; and still breaks\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-two-renders-one-dom\">The mental model: two renders, one DOM\u003C\u002Fa>","\u003Ca href=\"#fixing-it-stage-by-stage\">Fixing it, stage by stage\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":184,"html":185,"text":186,"type":150,"level":31},"b11","The problem: a page that&#39;s &quot;correct&quot; and still breaks","The problem: a page that's \"correct\" and still breaks",{"id":188,"html":189,"type":139},"b12","\u003Cp>Say you&#39;re building a &quot;tip of the day&quot; widget. It&#39;s a plain computed value, no fetch, no state management — about as simple as a Vue component gets:\u003C\u002Fp>",{"id":191,"code":192,"type":193,"language":194,"highlight":195},"b13","\u003Cscript setup>\nconst TIPS = [\n  \"Use useAsyncData for anything that fetches.\",\n  \"Auto-imports save you the import line, not the thinking.\",\n  \"Nitro is just Node under the hood.\",\n]\n\nconst tip = TIPS[Math.floor(Math.random() * TIPS.length)]\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>Tip of the day: {{ tip }}\u003C\u002Fp>\n\u003C\u002Ftemplate>","code","vue",[],{"id":197,"html":198,"type":139},"b14","\u003Cp>Nothing here looks wrong. It compiles, it runs, \u003Ccode>npm run dev\u003C\u002Fcode> shows a tip. But open the browser console and you&#39;ll see something like:\u003C\u002Fp>",{"id":200,"code":201,"type":193,"language":202,"highlight":203},"b15","[Vue warn]: Hydration text mismatch:\n- Server rendered:  Tip of the day: Nitro is just Node under the hood.\n- Client rendered:  Tip of the day: Use useAsyncData for anything that fetches.","plain",[],{"id":205,"html":206,"type":139},"b16","\u003Cp>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 &quot;tip&quot; were a price, a username, or which item was in stock, this wouldn&#39;t be a curiosity, it would be a bug report.\u003C\u002Fp>",{"id":208,"html":209,"type":139},"b17","\u003Cp>The same failure mode shows up with \u003Ccode>new Date()\u003C\u002Fcode>, with \u003Ccode>window.innerWidth\u003C\u002Fcode>, with anything read from \u003Ccode>localStorage\u003C\u002Fcode> inside the component&#39;s render path. The common thread: the value depends on \u003Cem>where\u003C\u002Fem> the code runs, and Nuxt runs your component in two different places.\u003C\u002Fp>",{"id":211,"html":212,"text":212,"type":150,"level":31},"b18","The mental model: two renders, one DOM",{"id":214,"html":215,"type":139},"b19","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> Nuxt doesn&#39;t render your app once — it renders the same component tree twice, in two different environments, and then asks the second render to \u003Cem>adopt\u003C\u002Fem> the DOM the first render already produced, instead of rebuilding it from scratch.\u003C\u002Fp>",{"id":217,"html":218,"type":139},"b20","\u003Cp>Here&#39;s the sequence for a single page request:\u003C\u002Fp>",{"id":220,"type":156,"items":221,"ordered":17},"b21",[222,223,224],"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 \u003Cstrong>payload\u003C\u002Fstrong>: the results of every \u003Ccode>useAsyncData\u003C\u002Fcode>\u002F\u003Ccode>useFetch\u003C\u002Fcode> call and every \u003Ccode>useState\u003C\u002Fcode>, embedded in the page as a \u003Ccode>&lt;script id=&quot;__NUXT_DATA__&quot;&gt;\u003C\u002Fcode> 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 \u003Cem>same\u003C\u002Fem> Vue app, client-side. But instead of creating new DOM nodes the way a client-only SPA would, it runs in \u003Cstrong>hydration mode\u003C\u002Fstrong>: it walks the existing DOM the server produced, node by node, and attaches reactivity and event listeners to what&#39;s already there, reading the payload from step 1 so it doesn&#39;t have to re-fetch data the server already fetched.",{"id":226,"html":227,"type":139},"b22","\u003Cp>Hydration is a \u003Cem>reconciliation\u003C\u002Fem>, 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&#39;t, Vue has two options depending on how badly they disagree:\u003C\u002Fp>",{"id":229,"type":156,"items":230,"ordered":18},"b23",[231,232],"\u003Cstrong>A text or attribute mismatch\u003C\u002Fstrong> (a \u003Ccode>{{ tip }}\u003C\u002Fcode> 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.","\u003Cstrong>A structural mismatch\u003C\u002Fstrong> (a different tag, a different number of children — the kind you get from \u003Ccode>v-if\u003C\u002Fcode> branching differently on each side): Vue can&#39;t patch that in place. It throws away the mismatched subtree and re-renders it entirely client-side. That&#39;s real, visible re-work, and if a user had already interacted with something inside that subtree, the element they clicked no longer exists.",{"id":234,"html":235,"type":139},"b24","\u003Cp>The payload exists specifically so that data \u003Cem>is\u003C\u002Fem> safe across hydration — \u003Ccode>useAsyncData\u003C\u002Fcode>, \u003Ccode>useFetch\u003C\u002Fcode>, and \u003Ccode>useState\u003C\u002Fcode> all serialize their results, so the client reads the exact value the server used instead of recomputing it. (If you&#39;ve read the \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fuseasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1\">earlier episode on \u003Ccode>useAsyncData\u003C\u002Fcode> keys and dedupe\u003C\u002Fa>, this is the same payload that makes dedupe possible — it&#39;s doing double duty.) The danger is everything \u003Cem>outside\u003C\u002Fem> that mechanism: any value your template reads that isn&#39;t backed by \u003Ccode>useState\u003C\u002Fcode>\u002F\u003Ccode>useAsyncData\u003C\u002Fcode> and isn&#39;t guaranteed identical on both sides — \u003Ccode>Math.random()\u003C\u002Fcode>, \u003Ccode>Date.now()\u003C\u002Fcode>, \u003Ccode>window\u003C\u002Fcode>, \u003Ccode>navigator\u003C\u002Fcode>, \u003Ccode>localStorage\u003C\u002Fcode> — is a mismatch waiting to happen, because nothing carries it across the server→client boundary for you.\u003C\u002Fp>",{"id":237,"html":238,"text":238,"type":150,"level":31},"b25","Fixing it, stage by stage",{"id":240,"html":241,"text":242,"type":150,"level":43},"b26","Stage 1: defer the value with \u003Ccode>onMounted\u003C\u002Fcode>","Stage 1: defer the value with onMounted",{"id":244,"html":245,"type":139},"b27","\u003Cp>The tip-of-the-day bug and the &quot;current time&quot; bug are the same shape: a value that&#39;s \u003Cem>legitimately\u003C\u002Fem> 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&#39;re certain you&#39;re client-side:\u003C\u002Fp>",{"id":247,"code":248,"type":193,"language":194,"highlight":249},"b28","\u003Cscript setup>\nimport { ref, onMounted } from \"vue\"\n\nconst tip = ref(null)\n\nonMounted(() => {\n  const TIPS = [\"Use useAsyncData for anything that fetches.\", \"…\"]\n  tip.value = TIPS[Math.floor(Math.random() * TIPS.length)]\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>Tip of the day: {{ tip ?? \"Loading…\" }}\u003C\u002Fp>\n\u003C\u002Ftemplate>",[],{"id":251,"html":252,"type":139},"b29","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>onMounted\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":254,"html":255,"text":256,"type":150,"level":43},"b30","Stage 2: skip SSR entirely with \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode>","Stage 2: skip SSR entirely with \u003CClientOnly>",{"id":258,"html":259,"type":139},"b31","\u003Cp>Some content isn&#39;t &quot;slightly different&quot; between server and client — it can&#39;t exist on the server at all. A chart that measures its container&#39;s pixel width, a widget that reads \u003Ccode>localStorage\u003C\u002Fcode>, a third-party embed that expects \u003Ccode>window\u003C\u002Fcode>. For those, don&#39;t try to make the server render \u003Cem>something\u003C\u002Fem> — tell Nuxt not to render it there in the first place. \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> is auto-imported and does exactly that:\u003C\u002Fp>",{"id":261,"code":262,"type":193,"language":194,"highlight":263},"b32","\u003Ctemplate>\n  \u003CClientOnly>\n    \u003CUserLocalClock \u002F>\n    \u003Ctemplate #fallback>\n      \u003Cspan class=\"clock-placeholder\">--:--\u003C\u002Fspan>\n    \u003C\u002Ftemplate>\n  \u003C\u002FClientOnly>\n\u003C\u002Ftemplate>",[],{"id":265,"html":266,"type":139},"b33","\u003Cp>The default slot never runs on the server. The \u003Ccode>#fallback\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":268,"html":269,"type":139},"b34","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> doesn&#39;t resolve a mismatch — it removes the possibility of one, because nothing inside it is ever compared between two renders. There&#39;s only ever one render, on the client.\u003C\u002Fp>",{"id":271,"html":272,"text":273,"type":150,"level":43},"b35","Stage 3: the branch that looks like a fix but isn&#39;t","Stage 3: the branch that looks like a fix but isn't",{"id":275,"html":276,"type":139},"b36","\u003Cp>It&#39;s tempting to reach for Nuxt&#39;s environment flags — \u003Ccode>import.meta.server\u003C\u002Fcode> \u002F \u003Ccode>import.meta.client\u003C\u002Fcode> (the modern replacement for the older \u003Ccode>process.server\u003C\u002Fcode> \u002F \u003Ccode>process.client\u003C\u002Fcode>) — and branch your template directly on them:\u003C\u002Fp>",{"id":278,"code":279,"type":193,"language":194,"highlight":280},"b37","\u003C!-- Don't do this -->\n\u003Ctemplate>\n  \u003Cdiv v-if=\"import.meta.client\">Client-rendered content\u003C\u002Fdiv>\n  \u003Cdiv v-else>Server-rendered content\u003C\u002Fdiv>\n\u003C\u002Ftemplate>",[],{"id":282,"html":283,"type":139},"b38","\u003Cp>This guarantees a structural mismatch, every single time. On the server, \u003Ccode>import.meta.server\u003C\u002Fcode> is \u003Ccode>true\u003C\u002Fcode>, so the server emits the \u003Ccode>&lt;div&gt;\u003C\u002Fcode> from the \u003Ccode>v-else\u003C\u002Fcode> branch. On the client, during hydration, \u003Ccode>import.meta.client\u003C\u002Fcode> is \u003Ccode>true\u003C\u002Fcode>, so Vue&#39;s hydration walk expects the \u003Ccode>v-if\u003C\u002Fcode> branch — a different \u003Ccode>&lt;div&gt;\u003C\u002Fcode> than the one actually sitting in the DOM. Vue can&#39;t reconcile two different branches in place; it discards and re-renders. \u003Ccode>import.meta.client\u003C\u002Fcode>\u002F\u003Ccode>.server\u003C\u002Fcode> are genuinely useful for deciding \u003Cem>what code runs\u003C\u002Fem> (skip a browser-only import on the server, skip a Node-only one on the client) — they&#39;re the wrong tool for deciding what a hydrated template \u003Cem>renders\u003C\u002Fem>, because that decision has to be identical in both places by definition.\u003C\u002Fp>",{"id":285,"html":286,"text":287,"type":150,"level":43},"b39","Stage 4: when a mismatch is real, expected, and fine — \u003Ccode>data-allow-mismatch\u003C\u002Fcode>","Stage 4: when a mismatch is real, expected, and fine — data-allow-mismatch",{"id":289,"html":290,"type":139},"b40","\u003Cp>Occasionally you&#39;ll have a value that will \u003Cem>always\u003C\u002Fem> differ by design — a relative timestamp (&quot;posted 3 minutes ago&quot;) that keeps ticking, for instance — and you&#39;ve already accepted that as correct behavior rather than a bug. Vue 3.5 added an attribute for exactly this: \u003Ccode>data-allow-mismatch\u003C\u002Fcode> silences the hydration warning for a specific element, scoped to the kind of mismatch you name (\u003Ccode>text\u003C\u002Fcode>, \u003Ccode>children\u003C\u002Fcode>, \u003Ccode>class\u003C\u002Fcode>, \u003Ccode>style\u003C\u002Fcode>, or \u003Ccode>attribute\u003C\u002Fcode>):\u003C\u002Fp>",{"id":292,"code":293,"type":193,"language":194,"highlight":294},"b41","\u003Ctime data-allow-mismatch=\"text\">{{ relativeTime }}\u003C\u002Ftime>",[],{"id":296,"html":297,"type":139},"b42","\u003Cp>This only suppresses the console warning — it does nothing to make the values agree. Reach for it after you&#39;ve decided the mismatch is cosmetic and harmless, never as a first response to a warning you haven&#39;t diagnosed yet.\u003C\u002Fp>",{"id":299,"html":300,"text":300,"type":150,"level":31},"b43","Edge cases and gotchas",{"id":302,"type":156,"items":303,"ordered":18},"b44",[304,305,306,307,308],"\u003Cstrong>Invalid HTML nesting causes mismatches with no logic bug at all.\u003C\u002Fstrong> A \u003Ccode>&lt;div&gt;\u003C\u002Fcode> nested inside a \u003Ccode>&lt;p&gt;\u003C\u002Fcode>, or malformed \u003Ccode>&lt;table&gt;\u003C\u002Fcode> markup, gets silently corrected by the browser&#39;s HTML parser while it parses the server&#39;s HTML — the browser closes the \u003Ccode>&lt;p&gt;\u003C\u002Fcode> early, restructuring the tree Vue expected to hydrate onto. The fix is markup hygiene, not JavaScript: keep nesting valid per the HTML content model.","\u003Cstrong>Browser extensions mutate the DOM before your JS runs.\u003C\u002Fstrong> Grammarly, password managers, and dark-mode extensions routinely inject attributes into the page before hydration starts. These aren&#39;t your bug and can&#39;t be reliably prevented; \u003Ccode>data-allow-mismatch=&quot;attribute&quot;\u003C\u002Fcode> on the affected element is the pragmatic escape valve once you&#39;ve confirmed the source.","\u003Cstrong>Server and client timezones differ.\u003C\u002Fstrong> A server running in UTC formatting a date directly in a template will disagree with a client in the visitor&#39;s local timezone. Same class of bug as \u003Ccode>Date.now()\u003C\u002Fcode> — same fix: compute the display string in \u003Ccode>onMounted\u003C\u002Fcode>.","\u003Cstrong>A \u003Ccode>ref\u003C\u002Fcode> seeded from a browser API at module or setup scope.\u003C\u002Fstrong> \u003Ccode>const isWide = ref(window.innerWidth &gt; 768)\u003C\u002Fcode> throws on the server (there is no \u003Ccode>window\u003C\u002Fcode>) or, if guarded, still needs a server-safe default and a client-side correction — the same \u003Ccode>onMounted\u003C\u002Fcode> pattern applies.","\u003Cstrong>Shared server state is a related but different bug.\u003C\u002Fstrong> If your mismatch is about the \u003Cem>wrong user&#39;s\u003C\u002Fem> data appearing rather than a timing difference, that&#39;s the cross-request state leak, not a hydration mismatch — see the \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnuxt-usestate-vs-ref-why-server-state-leaks-across-users-47n1\">earlier episode on \u003Ccode>useState\u003C\u002Fcode> vs. a plain \u003Ccode>ref\u003C\u002Fcode>\u003C\u002Fa> if that&#39;s the symptom you&#39;re chasing.",{"id":310,"html":311,"text":311,"type":150,"level":31},"b45","Best practices",{"id":313,"type":156,"items":314,"ordered":18},"b46",[315,316,317,318,319,320],"\u003Cstrong>Ask one question of every render-affecting expression:\u003C\u002Fstrong> given the same props and payload, does this produce the exact same output on the server and the client? If the honest answer is &quot;no,&quot; it doesn&#39;t belong directly in the template.","\u003Cstrong>Default first, correct in \u003Ccode>onMounted\u003C\u002Fcode>.\u003C\u002Fstrong> Any value that&#39;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.","\u003Cstrong>Reach for \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> for whole widgets, not individual values.\u003C\u002Fstrong> If an entire component only makes sense in a browser (canvas-sized charts, \u003Ccode>window\u003C\u002Fcode>-dependent libraries), don&#39;t fight it into an SSR-safe shape — skip SSR for it.","\u003Cstrong>Never branch a hydrated template&#39;s markup on \u003Ccode>import.meta.client\u003C\u002Fcode>\u002F\u003Ccode>.server\u003C\u002Fcode>.\u003C\u002Fstrong> Use those flags to decide what code \u003Cem>runs\u003C\u002Fem>, not what a hydrated component \u003Cem>renders\u003C\u002Fem>.","\u003Cstrong>Lint your markup.\u003C\u002Fstrong> Invalid HTML nesting is an easy, boring source of mismatches that a markup or accessibility linter catches before it ever reaches a browser.","\u003Cstrong>Test against a production build, not just \u003Ccode>nuxt dev\u003C\u002Fcode>.\u003C\u002Fstrong> Run \u003Ccode>nuxt build &amp;&amp; nuxt preview\u003C\u002Fcode> before shipping something that touches SSR — dev&#39;s warnings are the same, but dev&#39;s timing can mask issues that show up under real hydration.",{"id":322,"html":323,"text":323,"type":150,"level":31},"b47","FAQ",{"id":325,"html":326,"text":326,"type":150,"level":43},"b48","Does a hydration mismatch crash my app?",{"id":328,"html":329,"type":139},"b49","\u003Cp>No — Vue reconciles it either way. A text\u002Fattribute 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.\u003C\u002Fp>",{"id":331,"html":332,"text":332,"type":150,"level":43},"b50","Why does the warning only appear in development?",{"id":334,"html":335,"type":139},"b51","\u003Cp>Vue&#39;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 \u003Ccode>nuxt preview\u003C\u002Fcode> build, not just dev.\u003C\u002Fp>",{"id":337,"html":338,"text":339,"type":150,"level":43},"b52","Is \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> the same thing as checking \u003Ccode>import.meta.client\u003C\u002Fcode>?","Is \u003CClientOnly> the same thing as checking import.meta.client?",{"id":341,"html":342,"type":139},"b53","\u003Cp>No. \u003Ccode>import.meta.client\u003C\u002Fcode> is a compile-time flag that decides which lines of code are included in which bundle — it&#39;s a build-time tool. \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> 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&#39;s markup causes the exact mismatch this article is about; \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> avoids it by never hydrating that content at all.\u003C\u002Fp>",{"id":344,"html":345,"text":346,"type":150,"level":43},"b54","Does \u003Ccode>useState\u003C\u002Fcode> prevent hydration mismatches?","Does useState prevent hydration mismatches?",{"id":348,"html":349,"type":139},"b55","\u003Cp>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&#39;t protect a value your template computes independently of \u003Ccode>useState\u003C\u002Fcode> — \u003Ccode>Math.random()\u003C\u002Fcode> inside a \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> block is still a mismatch even if an unrelated \u003Ccode>useState\u003C\u002Fcode> call exists elsewhere in the same component.\u003C\u002Fp>",{"id":351,"html":352,"text":352,"type":150,"level":43},"b56","Can a mismatch happen even when my code is completely correct?",{"id":354,"html":355,"type":139},"b57","\u003Cp>Yes. Third-party scripts and browser extensions can alter the DOM before your app hydrates, and that&#39;s outside your code&#39;s control. \u003Ccode>data-allow-mismatch\u003C\u002Fcode> on the specific affected attribute is the accepted mitigation once you&#39;ve confirmed that&#39;s the cause.\u003C\u002Fp>",{"id":357,"html":358,"text":358,"type":150,"level":31},"b58","Cheat sheet",{"id":360,"head":361,"rows":365,"type":394},"b59",[362,363,364],"Situation","Symptom","Fix",[366,370,374,378,382,386,390],[367,368,369],"\u003Ccode>Math.random()\u003C\u002Fcode> \u002F \u003Ccode>Date.now()\u003C\u002Fcode> read during setup or render","Text mismatch warning, value flickers on load","Default to \u003Ccode>null\u003C\u002Fcode>\u002Fplaceholder, set the real value in \u003Ccode>onMounted\u003C\u002Fcode>",[371,372,373],"Reading \u003Ccode>window\u003C\u002Fcode>, \u003Ccode>navigator\u003C\u002Fcode>, \u003Ccode>localStorage\u003C\u002Fcode> in the template&#39;s data path","Throws on server, or mismatches if guarded naively","\u003Ccode>ref(defaultValue)\u003C\u002Fcode> + \u003Ccode>onMounted\u003C\u002Fcode> to correct it",[375,376,377],"Whole widget only makes sense client-side (canvas size, browser-only lib)","Mismatch or server crash","Wrap it in \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> with a \u003Ccode>#fallback\u003C\u002Fcode>",[379,380,381],"\u003Ccode>v-if=&quot;import.meta.client&quot;\u003C\u002Fcode> branching a hydrated template","Structural mismatch, guaranteed, every load","Don&#39;t branch markup on the flag — use \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode>\u002F\u003Ccode>onMounted\u003C\u002Fcode> instead",[383,384,385],"Relative time \u002F genuinely-expected drift you&#39;ve accepted","Warning you don&#39;t want to see","\u003Ccode>data-allow-mismatch=&quot;text&quot;\u003C\u002Fcode> (Vue 3.5+) — after you&#39;ve confirmed it&#39;s harmless",[387,388,389],"\u003Ccode>&lt;div&gt;\u003C\u002Fcode> inside \u003Ccode>&lt;p&gt;\u003C\u002Fcode>, broken table markup","Mismatch with no obvious cause in your JS","Fix the HTML nesting; lint markup",[391,392,393],"Grammarly \u002F extensions injecting attributes","Attribute mismatch you can&#39;t reproduce locally without the extension","\u003Ccode>data-allow-mismatch=&quot;attribute&quot;\u003C\u002Fcode> on the affected element","table",{"id":396,"code":397,"type":193,"language":194,"highlight":398},"b60","\u003Cscript setup>\nimport { ref, onMounted } from \"vue\"\n\n\u002F\u002F Server-safe default — identical on both renders.\nconst clientValue = ref(null)\n\nonMounted(() => {\n  \u002F\u002F Runs only after hydration succeeds — safe to diverge here.\n  clientValue.value = computeSomethingClientOnly()\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ clientValue ?? \"Loading…\" }}\u003C\u002Fp>\n\n  \u003C!-- For whole subtrees that can never run on the server: -->\n  \u003CClientOnly>\n    \u003CBrowserOnlyWidget \u002F>\n    \u003Ctemplate #fallback>\u003Cspan>Loading…\u003C\u002Fspan>\u003C\u002Ftemplate>\n  \u003C\u002FClientOnly>\n\u003C\u002Ftemplate>",[],{"id":400,"html":401,"type":139},"b61","\u003C!-- playground:start -->",{"id":403,"html":404,"text":404,"type":150,"level":31},"b62","🎮 Try it yourself",{"id":406,"html":407,"type":139},"b63","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":409,"html":410,"type":139},"b64","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":412,"html":413,"type":139},"b65","\u003C!-- playground:end -->",{"id":415,"html":416,"text":416,"type":150,"level":31},"b66","Key takeaways",{"id":418,"type":156,"items":419,"ordered":18},"b67",[420,421,422,423],"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&#39;t guaranteed identical on both sides: \u003Ccode>Math.random()\u003C\u002Fcode>, \u003Ccode>Date.now()\u003C\u002Fcode>, or any direct read of a browser-only API.","\u003Ccode>onMounted\u003C\u002Fcode> fixes values that are allowed to differ once hydration is already done; \u003Ccode>&lt;ClientOnly&gt;\u003C\u002Fcode> fixes whole subtrees that can never run on the server; \u003Ccode>data-allow-mismatch\u003C\u002Fcode> only silences a warning you&#39;ve already confirmed is harmless.","Never branch a hydrated template&#39;s markup on \u003Ccode>import.meta.client\u003C\u002Fcode>\u002F\u003Ccode>.server\u003C\u002Fcode> — that&#39;s the one &quot;fix&quot; that reliably causes the exact bug it&#39;s trying to solve.",{"id":425,"html":426,"type":139},"b68","\u003C!-- quiz:start -->",{"id":428,"html":429,"text":429,"type":150,"level":31},"b69","🧠 Test yourself",{"id":431,"html":432,"type":139},"b70","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch\u002Fquiz\">Take the 9-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":434,"html":435,"type":139},"b71","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":437,"html":438,"type":139},"b72","\u003C!-- quiz:end -->",{"id":440,"html":441,"text":441,"type":150,"level":31},"b73","One more render to get right",{"id":443,"html":444,"type":139},"b74","\u003Cp>That tip-of-the-day widget from the top of this article has an honest fix now — a \u003Ccode>ref\u003C\u002Fcode> that starts \u003Ccode>null\u003C\u002Fcode> and fills in after mount, instead of a \u003Ccode>Math.random()\u003C\u002Fcode> call sitting directly in the render path. The bug was never really about randomness; it was about \u003Cem>where\u003C\u002Fem> the randomness ran, and Nuxt was always going to run it twice.\u003C\u002Fp>",{"id":446,"html":447,"type":139},"b75","\u003Cp>What&#39;s the strangest hydration mismatch you&#39;ve had to track down — a third-party script, a timezone, something stranger? Drop it in the comments; there&#39;s a decent chance someone else&#39;s next \u003Ccode>[Vue warn]\u003C\u002Fcode> is exactly the one you already solved.\u003C\u002Fp>",{"id":449,"type":450},"b76","divider",{"id":452,"html":453,"type":139},"b77","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":455,"html":456,"type":139},"b78","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":458,"type":156,"items":459,"ordered":18},"b79",[460,461,462],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nThis 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.\n\nThis 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\u002F` directory convention Nuxt 4 defaults to. Everything here also applies to Nuxt 3's `compatibilityVersion: 4` mode.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain exactly what \"hydration\" means in Nuxt and why a mismatch happens\n- Recognize the handful of code patterns that reliably cause one\n- Pick the right fix — `onMounted`, `\u003CClientOnly>`, or `data-allow-mismatch` — for each situation\n- Read a hydration warning and know which line of your code to blame\n- Avoid the \"fix\" that looks reasonable but guarantees a mismatch every time\n\n## Who this is for\n\nYou've built at least one Nuxt page with `\u003Cscript setup>` and know roughly what server-side rendering means (the server sends back real HTML instead of an empty `\u003Cdiv id=\"app\">`). You don't need prior SSR debugging experience — that's the point of this article.\n\n## Table of contents\n\n- [The problem: a page that's \"correct\" and still breaks](#the-problem-a-page-thats-correct-and-still-breaks)\n- [The mental model: two renders, one DOM](#the-mental-model-two-renders-one-dom)\n- [Fixing it, stage by stage](#fixing-it-stage-by-stage)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## The problem: a page that's \"correct\" and still breaks\n\nSay 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:\n\n```vue\n\u003Cscript setup>\nconst TIPS = [\n  \"Use useAsyncData for anything that fetches.\",\n  \"Auto-imports save you the import line, not the thinking.\",\n  \"Nitro is just Node under the hood.\",\n]\n\nconst tip = TIPS[Math.floor(Math.random() * TIPS.length)]\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>Tip of the day: {{ tip }}\u003C\u002Fp>\n\u003C\u002Ftemplate>\n```\n\nNothing here looks wrong. It compiles, it runs, `npm run dev` shows a tip. But open the browser console and you'll see something like:\n\n```\n[Vue warn]: Hydration text mismatch:\n- Server rendered:  Tip of the day: Nitro is just Node under the hood.\n- Client rendered:  Tip of the day: Use useAsyncData for anything that fetches.\n```\n\nNothing 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.\n\nThe 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.\n\n## The mental model: two renders, one DOM\n\n**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.\n\nHere's the sequence for a single page request:\n\n1. 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`\u002F`useFetch` call and every `useState`, embedded in the page as a `\u003Cscript id=\"__NUXT_DATA__\">` block.\n2. 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.\n3. 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.\n\nHydration 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:\n\n- **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.\n- **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.\n\nThe 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](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fuseasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1), 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`\u002F`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.\n\n## Fixing it, stage by stage\n\n### Stage 1: defer the value with `onMounted`\n\nThe 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:\n\n```vue\n\u003Cscript setup>\nimport { ref, onMounted } from \"vue\"\n\nconst tip = ref(null)\n\nonMounted(() => {\n  const TIPS = [\"Use useAsyncData for anything that fetches.\", \"…\"]\n  tip.value = TIPS[Math.floor(Math.random() * TIPS.length)]\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>Tip of the day: {{ tip ?? \"Loading…\" }}\u003C\u002Fp>\n\u003C\u002Ftemplate>\n```\n\n**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.\n\n### Stage 2: skip SSR entirely with `\u003CClientOnly>`\n\nSome 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. `\u003CClientOnly>` is auto-imported and does exactly that:\n\n```vue\n\u003Ctemplate>\n  \u003CClientOnly>\n    \u003CUserLocalClock \u002F>\n    \u003Ctemplate #fallback>\n      \u003Cspan class=\"clock-placeholder\">--:--\u003C\u002Fspan>\n    \u003C\u002Ftemplate>\n  \u003C\u002FClientOnly>\n\u003C\u002Ftemplate>\n```\n\nThe 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.\n\n**Key concept:** `\u003CClientOnly>` 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.\n\n### Stage 3: the branch that looks like a fix but isn't\n\nIt's tempting to reach for Nuxt's environment flags — `import.meta.server` \u002F `import.meta.client` (the modern replacement for the older `process.server` \u002F `process.client`) — and branch your template directly on them:\n\n```vue\n\u003C!-- Don't do this -->\n\u003Ctemplate>\n  \u003Cdiv v-if=\"import.meta.client\">Client-rendered content\u003C\u002Fdiv>\n  \u003Cdiv v-else>Server-rendered content\u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n\nThis guarantees a structural mismatch, every single time. On the server, `import.meta.server` is `true`, so the server emits the `\u003Cdiv>` 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 `\u003Cdiv>` 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`\u002F`.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.\n\n### Stage 4: when a mismatch is real, expected, and fine — `data-allow-mismatch`\n\nOccasionally 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`):\n\n```vue\n\u003Ctime data-allow-mismatch=\"text\">{{ relativeTime }}\u003C\u002Ftime>\n```\n\nThis 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.\n\n## Edge cases and gotchas\n\n- **Invalid HTML nesting causes mismatches with no logic bug at all.** A `\u003Cdiv>` nested inside a `\u003Cp>`, or malformed `\u003Ctable>` markup, gets silently corrected by the browser's HTML parser while it parses the server's HTML — the browser closes the `\u003Cp>` early, restructuring the tree Vue expected to hydrate onto. The fix is markup hygiene, not JavaScript: keep nesting valid per the HTML content model.\n- **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.\n- **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 in `onMounted`.\n- **A `ref` seeded from a browser API at module or setup scope.** `const isWide = ref(window.innerWidth > 768)` throws on the server (there is no `window`) or, if guarded, still needs a server-safe default and a client-side correction — the same `onMounted` pattern applies.\n- **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 `useState` vs. a plain `ref`](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnuxt-usestate-vs-ref-why-server-state-leaks-across-users-47n1) if that's the symptom you're chasing.\n\n## Best practices\n\n- **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.\n- **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.\n- **Reach for `\u003CClientOnly>` 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.\n- **Never branch a hydrated template's markup on `import.meta.client`\u002F`.server`.** Use those flags to decide what code *runs*, not what a hydrated component *renders*.\n- **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.\n- **Test against a production build, not just `nuxt dev`.** Run `nuxt build && nuxt preview` before shipping something that touches SSR — dev's warnings are the same, but dev's timing can mask issues that show up under real hydration.\n\n## FAQ\n\n### Does a hydration mismatch crash my app?\n\nNo — Vue reconciles it either way. A text\u002Fattribute 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.\n\n### Why does the warning only appear in development?\n\nVue'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.\n\n### Is `\u003CClientOnly>` the same thing as checking `import.meta.client`?\n\nNo. `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. `\u003CClientOnly>` 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; `\u003CClientOnly>` avoids it by never hydrating that content at all.\n\n### Does `useState` prevent hydration mismatches?\n\nIt 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 `\u003Cscript setup>` block is still a mismatch even if an unrelated `useState` call exists elsewhere in the same component.\n\n### Can a mismatch happen even when my code is completely correct?\n\nYes. 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.\n\n## Cheat sheet\n\n| Situation | Symptom | Fix |\n| --- | --- | --- |\n| `Math.random()` \u002F `Date.now()` read during setup or render | Text mismatch warning, value flickers on load | Default to `null`\u002Fplaceholder, set the real value in `onMounted` |\n| Reading `window`, `navigator`, `localStorage` in the template's data path | Throws on server, or mismatches if guarded naively | `ref(defaultValue)` + `onMounted` to correct it |\n| Whole widget only makes sense client-side (canvas size, browser-only lib) | Mismatch or server crash | Wrap it in `\u003CClientOnly>` with a `#fallback` |\n| `v-if=\"import.meta.client\"` branching a hydrated template | Structural mismatch, guaranteed, every load | Don't branch markup on the flag — use `\u003CClientOnly>`\u002F`onMounted` instead |\n| Relative time \u002F 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 |\n| `\u003Cdiv>` inside `\u003Cp>`, broken table markup | Mismatch with no obvious cause in your JS | Fix the HTML nesting; lint markup |\n| Grammarly \u002F extensions injecting attributes | Attribute mismatch you can't reproduce locally without the extension | `data-allow-mismatch=\"attribute\"` on the affected element |\n\n```vue\n\u003Cscript setup>\nimport { ref, onMounted } from \"vue\"\n\n\u002F\u002F Server-safe default — identical on both renders.\nconst clientValue = ref(null)\n\nonMounted(() => {\n  \u002F\u002F Runs only after hydration succeeds — safe to diverge here.\n  clientValue.value = computeSomethingClientOnly()\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ clientValue ?? \"Loading…\" }}\u003C\u002Fp>\n\n  \u003C!-- For whole subtrees that can never run on the server: -->\n  \u003CClientOnly>\n    \u003CBrowserOnlyWidget \u002F>\n    \u003Ctemplate #fallback>\u003Cspan>Loading…\u003C\u002Fspan>\u003C\u002Ftemplate>\n  \u003C\u002FClientOnly>\n\u003C\u002Ftemplate>\n```\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Key takeaways\n\n- 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.\n- 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.\n- `onMounted` fixes values that are allowed to differ once hydration is already done; `\u003CClientOnly>` fixes whole subtrees that can never run on the server; `data-allow-mismatch` only silences a warning you've already confirmed is harmless.\n- Never branch a hydrated template's markup on `import.meta.client`\u002F`.server` — that's the one \"fix\" that reliably causes the exact bug it's trying to solve.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 9-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## One more render to get right\n\nThat 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.\n\nWhat'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.\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":108,"canonical":465,"description":109},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnuxt-weekly-hydration-mismatch","01a0b07e-f66a-736f-a8f4-416749e4a147",{"name":468,"part":43,"total":43,"items":469},"Nuxt Deep Dive",[470,474,478],{"slug":471,"title":472,"publishedAt":473,"readingMinutes":111},"nuxt-weekly-cross-request-state-leak","Nuxt useState vs ref(): Why Server State Leaks Across Users","2026-08-30T11:37:11.335Z",{"slug":475,"title":476,"publishedAt":477,"readingMinutes":111},"nuxt-weekly-useasyncdata-keys-dedupe","useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug","2026-09-13T11:26:28.014Z",{"slug":46,"title":108,"publishedAt":112,"readingMinutes":111},{"id":480,"locked":18},"01a0b07e-f6c5-764d-beb3-437963160e14",[482],{"id":45,"slug":46,"title":48,"_count":483},{"questions":51},[485],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":487,"questionCount":51},{"questions":51}]