Hydration errors

Catalogue of causes

The eighteen ways a Nuxt app renders differently in Node and in the browser, what each one looks like in the console, and the Nuxt-native fix for each.

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.

The catalogue

#CauseWhat you seeFix
1Non-deterministic values in render: Math.random(), Date.now(), crypto.randomUUID()text or attribute mismatchCompute once in a useState initializer or useAsyncData; useId() for ids
2Locale/timezone formatting (toLocaleDateString(), Intl.*) where the server is UTC/en and the user is nottext mismatch on dates and numbersShip 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
3Browser-only APIs read during setup (window, localStorage, navigator, matchMedia)server renders the default, client renders the "real" valueGuard with import.meta.client and update in onMounted; prefer useCookie for persisted preferences (readable on both sides); <ClientOnly> for whole subtrees
4Auth state kept in localStorage or memorylogged-out HTML, logged-in clientCookie or session based auth (useCookie, server sessions such as nuxt-auth-utils) so the server knows the user
5Invalid HTML nesting (<div> inside <p>, <a> inside <a>, <tr> outside <tbody>, <li> outside a list)children mismatch, odd DOMThe browser's parser "corrects" the HTML, so the DOM you hydrate against is not what you rendered; fix the markup
6v-html with a sanitizer that behaves differently on server and client, or markdown rendered with different optionschildren mismatchSame library and same config on both sides, or render server-only (island)
7Browser extensions or third-party scripts mutating the DOM before hydration (Grammarly, translators, ad blockers)attribute/class mismatch, only for some usersReproduce in a clean profile; defer third-party scripts until after hydration (@nuxt/scripts useScript triggers); allow-list with data-allow-mismatch where relevant
8Conditional rendering on import.meta.client in a templatemismatch by construction<ClientOnly> with a #fallback slot
9useState key collisions (same explicit key in two unrelated places)wrong data hydratesUnique, namespaced keys; rely on the auto-generated key when possible
10Data fetched with raw $fetch/fetch in setupserver has data, client renders empty or refetches firstuseAsyncData/useFetch, so the result travels in the payload
11Cross-request state pollution (module-scope ref, singleton Pinia or i18n instance on the server)one user's HTML contains another user's data, then mismatchesuseState, per-app stores, event.context; see Memory leaks
12<Teleport> to a target that does not exist in the SSR HTMLteleported content missing or duplicatedTeleport to #teleports (the only target Nuxt renders during SSR) or wrap in <ClientOnly> for other targets
13Iteration-order differences (object key order after different JSON handling, Set/Map insertion order)children mismatch in listsSort deterministically before rendering
14Layout that depends on the viewport (window.innerWidth, useMediaQuery)style/class mismatchCSS media queries; or render the SSR default and accept the post-mount change; client-hint headers as a last resort
15Locale detection from different sources (browser language on the client, cookie or route on the server)wrong-language text mismatchDetect from the same source on both sides (cookie or route prefix)
16Stale 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 deploysPurge caches on deploy; experimental.emitRouteChunkError: 'automatic' (default) plus the app manifest handle chunk errors; version your cache keys
17Personalised content cached without varying on cookiessomeone else's HTMLNever 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
18DOM 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

The causes that matter most

1. Non-deterministic values

<template>
  <footer :id="`stamp-${Math.random().toString(36).slice(2)}`">
    Rendered {{ new Date().toISOString() }}
  </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.

2. Locale and timezone formatting

<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>

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".

3. Browser-only APIs in setup

<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>

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.

5. Invalid HTML nesting

<template>
  <p class="lead">
    <div class="lead__title">{{ title }}</div>
    <a :href="href">Read more <a :href="docsHref">(docs)</a></a>
  </p>
</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.

8. Conditional rendering on 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>

<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.

10. Raw $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>

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.

12. Teleport to a missing target

<template>
  <Teleport to="#modal-root">
    <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>.

Gotcha· import.meta.client guards crashes, not mismatches

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>.

Gotcha· Causes 16 and 17 are cache bugs wearing a hydration costume

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • In a playground, recreate causes 1, 2, 3, 5, 8 and 12, one commit each. For each, write down the mismatch type the console reports before fixing it with the table's remedy.
  • For cause 3, note the wrapper's class after hydration: it still says theme-light. Toggle the theme once and watch it correct itself; that is the "not repaired" behaviour.
  • For cause 5, run curl -s localhost:3000 | npx prettier --parser html and compare against document.documentElement.outerHTML from a tab with JavaScript disabled.
  • Recreate cause 16 locally: nuxt build, load a page, rebuild with a changed component, then navigate client-side in the old tab and observe the chunk error handling.

Be able to say

Be able to say· What are the top causes of hydration mismatches you have actually seen?

"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."