When working with Vue.js (or Nuxt.js) in an SSR/SSG context, one of the more perplexing issues you may run into is a hydration mismatch warning. It shows up as something like:
“Hydration completed but contains mismatches.”
“Text content does not match server-rendered HTML.”
These warnings aren’t just cosmetic — they signal a deeper problem: the markup produced on the server doesn’t match what the client expects, which can lead to broken interactivity, flickering UI, and subtle state bugs. In this post I’ll walk you through:
- what hydration really means in Vue
- how mismatches manifest
- why they matter
- typical root causes (with expanded examples)
- detailed strategies and code patterns to avoid or fix them
Whether you’re building a static site, a server-rendered app, or a hybrid, these tips should help you get cleaner hydration and more robust UX.
What is Hydration in Vue?
In a server-rendered app (SSR or SSG) you typically generate HTML on the server, send that markup to the browser, and then on the client side Vue “hydrates” that HTML: it attaches event listeners, makes the DOM reactive, binds data, etc. The goal is to reuse the server‐rendered markup rather than destroying it and rebuilding from scratch.
A hydration mismatch happens when the DOM that Vue expects on the client doesn’t exactly match what the server sent. For example, maybe the server sent <div>5</div>
but the client tries to hydrate <div>6</div>
. Vue will detect this discrepancy and log a warning, and in some cases it may re-render whole parts of the tree, which undermines the performance and correctness benefits of SSR.
Why Hydration Mismatches Matter
At first glance you might think: “Well okay, the page still loads, what’s the big deal?” The truth is: a mismatch can lead to several real‐world UX and maintenance problems:
- Broken interactivity: Buttons, forms or other components might stop working because Vue skipped or remounted nodes unexpectedly.
- Layout flicker or jumps: The server delivered one structure, the client swapped or re-rendered it, causing visible shifts.
- Inconsistent state: If server and client have different initial state, you might get bugs that are hard to reproduce locally.
- SEO/analytics issues: If the client ends up with different markup than what crawlers indexed, you risk content mismatch or mis‐tracking.
So, while a warning may seem benign, it’s often better to address it proactively.
Common Scenarios Causing Hydration Mismatches
Below are typical patterns that cause HTML divergence between server & client. I’ve included more detailed variations and code samples than you often see.
1. Using Browser APIs or Environment-Specific Globals During SSR
During server rendering, you don’t have access to window
, document
, localStorage
, navigator
, or other browser-specific APIs. Using them directly can cause weird mismatches.
❌ Example
<template>
<div>The width is: {{ window.innerWidth }}</div>
</template>
<script setup>
const width = window.innerWidth
</script>
What happens: On the server window
is undefined (or you might wrap it but produce undefined
), so the server renders something like The width is:
(empty). On the client the width might be 1024
, so the markup becomes different → mismatch.
✅ Fixed version
<template>
<div v-if="width !== null">The width is: {{ width }}</div>
<div v-else>Loading…</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const width = ref(null)
onMounted(() => {
width.value = window.innerWidth
})
</script>
Here:
- On the server
width
isnull
so it shows “Loading…” → markup A - On the client after mount
width
becomes say1024
and shows “The width is: 1024” → markup B Since the server and client HTML differ intentionally and we allow for the difference via conditional, Vue sees no unexpected mismatch.
Tip
- Wrap browser-only code in
onMounted()
(Vue) orif (process.client)
(Nuxt) or equivalent. - For universal code, guard usage of
window
,document
,navigator
, etc. - Avoid reading browser state synchronously during render.
2. Non-Deterministic Output (Randoms, Timestamps, Generated IDs)
If your template generates something that changes between server and client, you’ll get mismatches.
❌ Example
<template>
<p>User ID: {{ Math.random() }}</p>
</template>
Every render (server vs client) will produce a different random number → mismatch.
✅ Fixed version
- If you need a unique ID, generate it once on the server, pass it as a prop, or use a deterministic function.
- Avoid usage of
new Date()
,Math.random()
inside templates or computed properties that run both SSR and CSR unless you intentionally handle differences.
Example:
// server code or Nuxt asyncData
const userId = generateUserId() // deterministic
<template>
<p>User ID: {{ userId }}</p>
</template>
<script setup>
defineProps({ userId: String })
</script>
Now the server sends the same userId
that the client will hydrate with.
3. Data Only Fetched on Client Side
If your server-rendered HTML has placeholders (or nothing) because the data is fetched only on the client, then the markup ends up different on hydration.
❌ Example in Nuxt
<template>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const users = ref([])
onMounted(async () => {
users.value = await fetchUsers()
})
</script>
Server renders <ul></ul>
(empty list). Client mounts and fetches data, then re-renders <ul><li>...users...</li></ul>
. That difference triggers mismatch.
✅ Fixed version via SSR-aware fetch
In Nuxt you’d use useAsyncData
or useFetch()
so the data is fetched on the server and client:
<script setup>
const { data: users } = await useAsyncData('users', () => $fetch('/api/users'))
</script>
<template>
<ul>
<li v-for="user in users.value" :key="user.id">{{ user.name }}</li>
</ul>
</template>
Now server and client will render the same list initially, so no mismatch.
Tip
- Ensure that any “initial” UI that depends on data is rendered on the server if you rely on SSR.
- If you intentionally defer to client (e.g., “load later”), then make sure server and client markup differ in a controlled way (e.g., show placeholder on both).
4. Conditional Rendering Based on Client-Only Logic
When you conditionally render UI based on something only known on the client (theme preference, viewport size, cookie value, localStorage, etc.), you can produce different markup between server and client.
❌ Example
<template>
<div>
{{ isDark ? '🌙 Dark mode' : '☀️ Light mode' }}
</div>
</template>
<script setup>
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
</script>
Server has no window.matchMedia
, so isDark
might default to false
(or error) and render “☀️ Light mode”. Client detects dark mode, sets isDark=true
, re-renders “🌙 Dark mode” → mismatch.
✅ Better version
Approach #1: Delayed content so server shows a neutral placeholder and client picks up correct view.
<template>
<div v-if="mounted">
{{ isDark ? '🌙 Dark mode' : '☀️ Light mode' }}
</div>
<div v-else>
Loading…
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const mounted = ref(false)
const isDark = ref(false)
onMounted(() => {
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
mounted.value = true
})
</script>
Here: server renders “Loading…”. Client hydrates and shows the correct “Dark mode” or “Light mode”. Since the server and client markup intentionally differ and Vue knows about the difference via v-if
, you avoid mismatches.
Approach #2: If using Nuxt, you might detect theme preference server-side (via cookie or header) so the server renders the correct mode already.
Tip
- For things like theme, viewport size, localStorage preferences: handle them after mount, or pass them as props from server when possible.
- Avoid branching markup based purely on browser state in SSR render.
5. Asynchronous Components or Lazy-Loaded Pieces
Sometimes you lazily load a component that changes the markup tree after hydration, or you use different markup for server vs client intentionally but don’t account for it fully.
Example: A component only loaded on client to display a heavy UI (like a map). On server you might render a placeholder <div>Loading map…</div>
but on client you replace with a large <div><map>…</map></div>
. If the structure or tags differ significantly, hydration warnings appear.
Fix: Use client-only
wrappers (in Nuxt) or render the same structure just differing in internal content. For example:
<template>
<div class="map-wrapper">
<ClientOnly>
<MapComponent :data="mapData" />
</ClientOnly>
<div v-else class="map-placeholder">Loading map…</div>
</div>
</template>
This ensures the wrapper <div class="map-wrapper">
is stable between server/client, improving hydration consistency.
Best Practices: Ensuring Safe and Predictable Hydration
Here are more detailed recommendations and patterns I’ve found helpful:
- Guard browser-only APIs
if (typeof window !== 'undefined') {
// safe to use window
}
Or in Vue setup:
import { onMounted } from 'vue'
onMounted(() => {
// run code after hydration
})
- Avoid nondeterministic rendering
- Don’t use
Math.random()
inside template or computed properties used in SSR. - Don’t rely on
Date.now()
for visible output unless you accept the mismatch. - If unique IDs are required, generate server-side and pass to client.
- Keep logic pure and predictable for SSR.
- Fetch data on server & client (if you’re using SSR/SSG)
- In Nuxt:
useAsyncData
oruseFetch
. - For custom SSR setups: ensure server resolves data before rendering, and the client hydrates with the same initial data.
- Keep UI structure consistent
- The outermost DOM tree should not change between server and client.
- If you use
v-if
,v-else
, or conditional components, ensure both branches are valid in SSR or you render a placeholder. - For layout/skins/themes, decide server‐side if possible, or mark sections as client‐only.
- Test hydration locally early
- Run your build in SSR mode and inspect the browser console for hydration warnings.
- Inspect markup differences (view Source vs DevTools) to spot mismatches.
- Use logging or snapshots in dev to compare server-caused markup vs client.
- Use
key
attributes carefully
- When looping or rendering lists, ensure
:key
is stable between server and client. - If a key depends on a client-only value, the client might re‐render with different keys → mismatch or remounting.
- Limit complexity in SSR-rendered pieces
- Complex interactive components that rely on rich browser APIs are candidates for “client-only” rendering, but design them so that the server still sends a valid placeholder.
- Avoid huge interactive UI differences that flip entire subtrees post-hydrate.
Real-World Expanded Example
Let’s walk through a more realistic scenario: a blog site built with Nuxt, where we display a “time since published” label, a theme toggle, and a list of comments loaded via API.
Scenario
- We render posts server-side with date info.
- We have a theme toggle (light/dark) based on user preference stored in
localStorage
. - Comments are fetched via API and inserted under each post.
Potential pitfalls & fixes
Problem A: “time since published”
<template>
<span>{{ timeSince(publishedAt) }} ago</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ publishedAt: String })
function timeSince(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime()
return Math.floor(diff / (1000 * 60)) + ' min'
}
const timeLabel = computed(() => timeSince(props.publishedAt))
</script>
Issue: On server, Date.now()
is time at build/render; on client it’s later → difference. Mismatch.
Fix:
- Option 1: Compute the difference server-side and pass a fixed label.
- Option 2: Render a placeholder on server and compute actual value on client after mount. Example:
<template>
<span v-if="mounted">{{ timeLabel }} ago</span>
<span v-else>Loading time…</span>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({ publishedAt: String })
const mounted = ref(false)
const timeLabel = ref('')
onMounted(() => {
const diff = Date.now() - new Date(props.publishedAt).getTime()
timeLabel.value = Math.floor(diff / (1000 * 60))
mounted.value = true
})
</script>
Now server outputs “Loading time…”, client hydrates and shows actual minutes. No unexpected mismatch.
Problem B: Theme toggle
<template>
<button @click="toggleTheme">
{{ theme === 'dark' ? '🌙 Dark' : '☀️ Light' }}
</button>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const theme = ref('light')
onMounted(() => {
theme.value = localStorage.getItem('theme') || 'light'
})
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
</script>
Issue: On server = no localStorage
, so theme defaults to light
→ server renders “☀️ Light”. On client maybe user has dark theme preference so it becomes “🌙 Dark” → mismatch.
Fix: Use “client only” rendering for theme button, or include server-side detection if possible. Example:
<template>
<div v-if="mounted">
<button @click="toggleTheme">
{{ theme === 'dark' ? '🌙 Dark' : '☀️ Light' }}
</button>
</div>
<div v-else>
<!-- blank or loading placeholder, same on server & client pre-hydrate -->
<button disabled>Loading theme…</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const mounted = ref(false)
const theme = ref('light')
onMounted(() => {
theme.value = localStorage.getItem('theme') || 'light'
mounted.value = true
})
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
</script>
Now server and client before hydration show the same placeholder button. After hydration client shows actual state without Vue complaining.
Problem C: Comments list fetched only on client
<template>
<div>
<h3>Comments</h3>
<ul>
<li v-for="c in comments" :key="c.id">{{ c.text }}</li>
</ul>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const comments = ref([])
onMounted(async () => {
comments.value = await fetchComments()
})
</script>
Issue: Server renders <ul></ul>
(empty). On client after fetch it becomes <ul><li>…</li></ul>
. Mismatch.
Fix: Fetch server‐side (if you want SSR) or render a placeholder list on server. Example using Nuxt’s useAsyncData
:
<script setup>
const { data: comments } = await useAsyncData('comments', () => $fetch('/api/comments'))
</script>
<template>
<div>
<h3>Comments</h3>
<ul>
<li v-for="c in comments.value" :key="c.id">{{ c.text }}</li>
</ul>
</div>
</template>
Now server and client both render the same comments at hydration time.
Final Thoughts
Hydration mismatches in Vue or Nuxt are more than just irritating console warnings — they point to a fundamental divergence between your server-rendered output and your client’s expectations. The key to avoiding them:
- keep your server and client rendering logic aligned
- avoid injecting browser-only state into SSR output without guard
- make data fetching deterministic and ahead of hydration
- keep markup structure stable across environments
- test your SSR build and catch mismatches early
In practice when you adopt these patterns you end up with smoother, flicker-free UI, fewer console warnings, and a much more predictable SSR/CSR experience.
So next time you see “Hydration completed but contains mismatches”, don’t ignore it — dig in, find which part of your render differs between server and client, and apply one of the fixes above. Happy coding! 🚀
Top comments (1)
This post brilliantly demystifies the root causes of hydration mismatches in Vue and Nuxt apps, which are often overlooked but critical to UX and SEO. At Hashbyt, we stress ensuring the server and client render logic stays perfectly in sync, especially when dealing with browser-only APIs or non-deterministic data like timestamps. Leveraging lifecycle hooks like onMounted to defer browser-dependent rendering has saved us from countless flickers and errors. Curious, what’s your preferred strategy for managing theme or locale detection that needs both SSR and CSR consistency?