Every hydration bug reduces to "some input existed on only one side", but interviewers ask for concrete causes, and a candidate who can name ten from memory, say what each looks like in the console and give the idiomatic fix is clearly someone who has debugged them. The table is the reference; the sections below expand the causes that account for most real incidents.
| # | Cause | What you see | Fix |
|---|---|---|---|
| 1 | Non-deterministic values in render: Math.random(), Date.now(), crypto.randomUUID() | text or attribute mismatch | Compute once in a useState initializer or useAsyncData; useId() for ids |
| 2 | Locale/timezone formatting (toLocaleDateString(), Intl.*) where the server is UTC/en and the user is not | text mismatch on dates and numbers | Ship an ISO string through the payload and format in onMounted or <ClientOnly>; or pin locale and timeZone explicitly; or data-allow-mismatch="text" when the flash is acceptable |
| 3 | Browser-only APIs read during setup (window, localStorage, navigator, matchMedia) | server renders the default, client renders the "real" value | Guard with import.meta.client and update in onMounted; prefer useCookie for persisted preferences (readable on both sides); <ClientOnly> for whole subtrees |
| 4 | Auth state kept in localStorage or memory | logged-out HTML, logged-in client | Cookie or session based auth (useCookie, server sessions such as nuxt-auth-utils) so the server knows the user |
| 5 | Invalid HTML nesting (<div> inside <p>, <a> inside <a>, <tr> outside <tbody>, <li> outside a list) | children mismatch, odd DOM | The browser's parser "corrects" the HTML, so the DOM you hydrate against is not what you rendered; fix the markup |
| 6 | v-html with a sanitizer that behaves differently on server and client, or markdown rendered with different options | children mismatch | Same library and same config on both sides, or render server-only (island) |
| 7 | Browser extensions or third-party scripts mutating the DOM before hydration (Grammarly, translators, ad blockers) | attribute/class mismatch, only for some users | Reproduce in a clean profile; defer third-party scripts until after hydration (@nuxt/scripts useScript triggers); allow-list with data-allow-mismatch where relevant |
| 8 | Conditional rendering on import.meta.client in a template | mismatch by construction | <ClientOnly> with a #fallback slot |
| 9 | useState key collisions (same explicit key in two unrelated places) | wrong data hydrates | Unique, namespaced keys; rely on the auto-generated key when possible |
| 10 | Data fetched with raw $fetch/fetch in setup | server has data, client renders empty or refetches first | useAsyncData/useFetch, so the result travels in the payload |
| 11 | Cross-request state pollution (module-scope ref, singleton Pinia or i18n instance on the server) | one user's HTML contains another user's data, then mismatches | useState, per-app stores, event.context; see Memory leaks |
| 12 | <Teleport> to a target that does not exist in the SSR HTML | teleported content missing or duplicated | Teleport to #teleports (the only target Nuxt renders during SSR) or wrap in <ClientOnly> for other targets |
| 13 | Iteration-order differences (object key order after different JSON handling, Set/Map insertion order) | children mismatch in lists | Sort deterministically before rendering |
| 14 | Layout that depends on the viewport (window.innerWidth, useMediaQuery) | style/class mismatch | CSS media queries; or render the SSR default and accept the post-mount change; client-hint headers as a last resort |
| 15 | Locale detection from different sources (browser language on the client, cookie or route on the server) | wrong-language text mismatch | Detect from the same source on both sides (cookie or route prefix) |
| 16 | Stale cached HTML after a deploy (CDN or SWR serving HTML whose payload and chunks do not match the new client bundle) | mismatches only right after deploys | Purge caches on deploy; experimental.emitRouteChunkError: 'automatic' (default) plus the app manifest handle chunk errors; version your cache keys |
| 17 | Personalised content cached without varying on cookies | someone else's HTML | Never cache personalised routes; varies or getKey in Nitro cache options; this is the shape of advisory GHSA-wm8w-6qjm-cv43, fixed in Nuxt 4.5.1 |
| 18 | DOM mutation in a plugin before mount (adding a body class the server did not render) | attribute mismatch on <body> or <html> | useHead({ bodyAttrs, htmlAttrs }) so unhead renders it on both sides |
<template>
<footer :id="`stamp-${Math.random().toString(36).slice(2)}`">
Rendered {{ new Date().toISOString() }}
</footer>
</template>
<script setup lang="ts">
import { useId } from 'vue'
// initializer runs on the server, the value travels in the payload, the client reuses it
const renderedAt = useState('build-stamp:rendered-at', () => new Date().toISOString())
const id = useId()
</script>
<template>
<footer :id="id">Rendered {{ renderedAt }}</footer>
</template>
The rule is not "never use Date.now()", it is "compute it once and share it". A useState initializer runs during SSR and its result is revived on the client before the first render; keep the value to what devalue can serialise (no classes, functions or symbols). Ids are a separate case: useId() is deterministic by design, so nothing has to travel.
<script setup lang="ts">
const props = defineProps<{ iso: string }>()
</script>
<template>
<!-- Node: en-US in UTC. Browser: whatever the user has. -->
<time :datetime="iso">{{ new Date(props.iso).toLocaleDateString() }}</time>
</template>
<script setup lang="ts">
const props = defineProps<{ iso: string }>()
// pin BOTH inputs the two runtimes disagree on
const formatter = new Intl.DateTimeFormat('cs-CZ', { dateStyle: 'medium', timeZone: 'Europe/Prague' })
const label = computed(() => formatter.format(new Date(props.iso)))
</script>
<template>
<time :datetime="iso">{{ label }}</time>
</template>
Pinning works when the product has one locale and timezone. When the value genuinely depends on the user, either derive locale from a source both sides can read (a cookie or the route prefix, cause 15), or format in onMounted and render the ISO string until then, or accept the flash and declare it with data-allow-mismatch="text".
<script setup lang="ts">
// the guard prevents the crash, not the mismatch: server says "light", client says "dark"
const theme = ref(import.meta.client ? localStorage.getItem('theme') ?? 'light' : 'light')
</script>
<template>
<div :class="`theme-${theme}`"><slot /></div>
</template>
<script setup lang="ts">
// the request carries the cookie, so server and client read the same value
const theme = useCookie<'light' | 'dark'>('theme', {
default: () => 'light',
maxAge: 60 * 60 * 24 * 365,
})
</script>
<template>
<div :class="`theme-${theme}`"><slot /></div>
</template>
This one is dangerous because a class mismatch is only logged, never repaired: the wrapper keeps theme-light while theme.value says dark. Preferences the server must render belong in a cookie; things the server genuinely cannot know (viewport, prefers-reduced-motion) are read in onMounted and the SSR default is accepted.
<template>
<p class="lead">
<div class="lead__title">{{ title }}</div>
<a :href="href">Read more <a :href="docsHref">(docs)</a></a>
</p>
</template>
<template>
<div class="lead">
<h2 class="lead__title">{{ title }}</h2>
<p>
<a :href="href">Read more</a>
<a :href="docsHref">(docs)</a>
</p>
</div>
</template>
The server string is exactly what your template says; the browser then parses it with the HTML spec's error recovery, closes the <p> before the <div> and splits the nested anchors, and Vue hydrates against that DOM. Vue 3.4 reports the nesting in development; a validator over the SSR output catches it in CI.
import.meta.client<script setup lang="ts">
const isClient = import.meta.client
</script>
<template>
<!-- server: nothing. client first render: a chart. Vue must mount it during hydration. -->
<RevenueChart v-if="isClient" :series="series" />
</template>
<template>
<ClientOnly>
<RevenueChart :series="series" />
<template #fallback>
<div class="chart-skeleton" aria-hidden="true" />
</template>
</ClientOnly>
</template>
<ClientOnly> renders the fallback on the server and on the client's first render, then swaps in the default slot after mount, so both trees agree. Its default slot is tree-shaken from the server build, which also means CSS used only inside it may not be inlined in the initial HTML.
$fetch in setup<script setup lang="ts">
// runs on the server, then runs again on the client: two requests, nothing in the payload
const items = await $fetch<Item[]>('/api/items')
</script>
<script setup lang="ts">
// fetched once on the server, serialised into the payload, reused by the client
const { data: items } = await useFetch<Item[]>('/api/items')
</script>
With a raw $fetch the client refetches while hydrating; any difference (fresher data, a server-side call that lacked the user's cookies, a different sort order) is a mismatch, and even identical data costs a duplicate request and delays interactivity. useAsyncData/useFetch keys the result, ships it in the payload and deduplicates. If the data really must be client-only, server: false makes both sides render the default first.
<template>
<Teleport to="#modal-root">
<dialog v-if="open">…</dialog>
</Teleport>
</template>
<template>
<!-- #teleports is the one target Nuxt renders during SSR -->
<Teleport to="#teleports">
<dialog v-if="open">…</dialog>
</Teleport>
</template>
Vue's server renderer collects teleported content into ssrContext.teleports, and Nuxt only writes the #teleports entry into the document. Any other selector produces HTML without the content and a client that expects it. For a target that must be arbitrary, wrap the <Teleport> in <ClientOnly>.
The guard makes the server not throw; it does nothing about the server rendering a different value. Whenever you write import.meta.client ? realValue : default, ask what the client's first render shows. If it is realValue, you have cause 3 or 8, and the answer is onMounted, useCookie or <ClientOnly>.
A CDN serving yesterday's HTML with today's chunks, or a cached response built for another user, both surface as mismatches that "only happen in production" and "only for some people". Check age and cache-status response headers before touching a component. Advisory GHSA-wm8w-6qjm-cv43 (Nuxt 4.4.0–4.5.0, fixed in 4.5.1) was the payload cache keyed by path alone, handing one user's SSR data to the next.
experimental.emitRouteChunkError defaults to 'automatic'; 'automatic-immediate' reloads as soon as a chunk fails instead of on the next navigation; appManifest and checkOutdatedBuildInterval are what detect a new build. Re-check the defaults before the interview.
class after hydration: it still says theme-light. Toggle the theme once and watch it correct itself; that is the "not repaired" behaviour.curl -s localhost:3000 | npx prettier --parser html and compare against document.documentElement.outerHTML from a tab with JavaScript disabled.nuxt build, load a page, rebuild with a changed component, then navigate client-side in the old tab and observe the chunk error handling."Almost always one of five families. Non-deterministic or environment-dependent values: Date.now(), Math.random(), locale and timezone formatting, which I fix by computing once in a useState initializer, pinning the formatter, or using useId for ids. Browser-only state read too early: localStorage, window, auth held in memory, which I move into a cookie the server can read or into onMounted. Invalid HTML nesting, where the browser corrects the markup and Vue hydrates against the corrected DOM. Data fetched outside the payload with a raw $fetch, fixed with useAsyncData. And things mutating the DOM before Vue gets to it: a plugin adding a body class, an extension, a Teleport to a target Nuxt never rendered. The two impostors are stale or personalised cached HTML, which look like hydration bugs and are cache bugs."
What hydration is
renderToString on the server, createSSRApp().mount() walking the DOM on the client, how Nuxt ships state so both renders agree, and what Vue does when they do not.
Diagnosing, step by step
A seven-step workflow from reading the warning to a production build that talks, and the E2E test that turns the manual check into a CI gate.