data-allow-mismatch sparingly for unavoidable differences like timestampsHydration failures are the silent killer of modern SEO. Google indexes a broken version of your site that humans never see.
Your site works perfectly in the browser. But Google's crawler hits it during hydration, freezes mid-process, and indexes the half-built DOM. Rankings tank and you never know why.
Server sends HTML. JavaScript makes it interactive. That process is hydration.
// Server renders this HTML
<div id="app">
<h1>Products</h1>
<ul>
<li>Product 1</li>
<li>Product 2</li>
</ul>
</div>
// Client hydrates: Vue takes over, attaches listeners
const app = createSSRApp(App)
app.mount('#app')
Vue creates the same app that ran on the server, matches components to DOM nodes, attaches event listeners. If server HTML and client expectations differ, you get a hydration mismatch.
Human browsers and Googlebot don't execute JavaScript the same way.
Real users get persistent execution, generous timeouts, GPU acceleration. Googlebot gets throttled execution, aggressive API cancellation, hard rendering cutoffs (The Better Web Movement).
When hydration fails or stalls, the browser freezes the DOM in a half-built state. Humans never see this—JavaScript eventually recovers. Search engines index the broken version (NRLC).
The fatal pattern:
You lose rankings because of content Google never shows in Search Console's rendered HTML view.
<script setup lang="ts">
// ❌ Breaks hydration - window doesn't exist on server
</script>
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const width = window.innerWidth
const theme = localStorage.getItem('theme')
// ✅ Fixed - only runs on client
const width = ref(0)
const theme = ref('light')
onMounted(() => {
width.value = window.innerWidth
theme.value = localStorage.getItem('theme') || 'light'
})
</script>
window, document, localStorage don't exist in Node.js. Use onMounted()—it only runs client-side.
<script setup lang="ts">
// ❌ Server and client generate different timestamps
</script>
<script setup lang="ts">
import { useAsyncData } from '#app'
const timestamp = Date.now()
// ✅ Fixed - fetch once, use everywhere
const { data: timestamp } = await useAsyncData('timestamp', () =>
Promise.resolve(Date.now()))
</script>
<template>
<div>{{ timestamp }}</div>
</template>
Server executes at build time. Client executes when user visits. Date.now(), Math.random(), API calls that return different data—all cause mismatches.
// ❌ Analytics injects content during hydration
<script>
gtag('config', 'GA_ID')
</script>
// ✅ Fixed - load after hydration
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
const script = document.createElement('script')
script.src = 'https://www.googletagmanager.com/gtag/js?id=GA_ID'
script.async = true
document.head.appendChild(script)
})
</script>
Analytics, chat widgets, ad scripts—anything that modifies DOM before hydration completes will cause mismatches. Load them after onMounted().
// ❌ Browser auto-corrects invalid HTML
<template>
<table>
<div>Invalid - div inside table</div>
</table>
</template>
// Server sends: <table><div>...</div></table>
// Browser corrects to: <div>...</div><table></table>
// Hydration fails
Browsers silently fix invalid HTML. Server sends one structure, browser corrects it, Vue expects the server version—mismatch (Vue.js docs).
Vue logs mismatches to console in development:
[Vue warn]: Hydration node mismatch:
- Client vnode: div
- Server rendered DOM: span
Check what Googlebot sees:
Compare to "View Page Source" in your browser. If they differ, you have a hydration problem.
Test with curl:
# See what Google gets initially
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com
# Should match browser's View Page Source
Vue 3.5+ selective suppression:
<template>
<!-- Suppress inevitable mismatches -->
<div data-allow-mismatch="text">
{{ timestamp }}
</div>
</template>
Use sparingly. Suppression doesn't fix the underlying issue—Google still sees mismatched content.
Hydration affects all Core Web Vitals (Antoine Eripret):
Target: LCP under 2.5s, INP ≤ 200ms, CLS < 0.1.
Hydration on big sites with deeply nested HTML significantly increases rendering time. Every component needs matching, every event listener needs attaching.
Hydrate only interactive components. Static content stays static (Markus Oberlehner).
// ❌ Hydrates everything
<template>
<article>
<h1>Blog Post</h1>
<p>Long static content...</p>
<CommentSection />
</article>
</template>
// ✅ Only hydrate comments
<template>
<article>
<h1>Blog Post</h1>
<p>Long static content...</p>
<ClientOnly>
<CommentSection />
</ClientOnly>
</article>
</template>
Lazy hydration - defer until needed:
import { defineAsyncComponent } from 'vue'
// Hydrate when visible in viewport
const CommentSection = defineAsyncComponent(() =>
import('./CommentSection.vue')
)
Tools for partial hydration:
<NuxtIsland> prevents hydrationPartial hydration can cut Time to Interactive by 50%+ on content-heavy sites (LogRocket).
Keep state consistent:
// ❌ Different server vs client
const isMobile = window.innerWidth < 768
// ✅ Same everywhere
const isMobile = computed(() => {
if (import.meta.client) {
return window.innerWidth < 768
}
return false // default for SSR
})
Fetch data properly:
// ❌ Client-only fetch causes empty server HTML
onMounted(async () => {
const data = await fetch('/api/products')
})
// ✅ Fetch server-side, hydrate with data
const { data } = await useAsyncData('products', () =>
$fetch('/api/products'))
Avoid browser-specific logic:
// ❌ Server crashes - navigator undefined
const userAgent = navigator.userAgent
// ✅ Check environment
const userAgent = import.meta.client ? navigator.userAgent : ''
Load third-party scripts after hydration:
Defer analytics, ads, chat widgets to onMounted(). They don't need to hydrate—they inject after.
Not every app needs perfect hydration for SEO.
Skip hydration worries for:
If Google doesn't need to see it, hydration mismatches don't hurt SEO.
Nuxt provides hydration debugging tools and automatic detection.
Check out Nuxt Delay Hydration for optimized hydration with minimal setup. Nuxt also offers <NuxtIsland> for partial hydration and detailed hydration error messages.