---
title: "Hydration Mismatches and SEO in Vue"
description: "Google can index the broken DOM state Vue leaves behind after a failed hydration. Debug mismatches, fix common causes, and ship partial hydration."
canonical_url: "https://nuxtseo.com/learn-seo/vue/spa/hydration"
last_updated: "2026-07-16"
---

<key-takeaways>

- A hydration mismatch can leave Googlebot indexing a half-built DOM if it times out before Vue finishes recovering, and your own browser never shows you that broken state
- Hydration blocks the main thread while Vue attaches event listeners, which is the main driver of a poor INP score on client-rendered pages
- Common causes are browser-only APIs running during SSR, server and client producing different data, and invalid HTML that the browser silently rewrites before Vue can match it
- Use `data-allow-mismatch` sparingly, for genuinely unavoidable differences like timestamps

</key-takeaways>

When hydration fails, Google can index a broken version of a page that no human ever sees. A real browser recovers from a hydration mismatch before most users notice: Vue discards the broken nodes, remounts, and the page keeps working. Googlebot doesn't get the same runway.

## What is Hydration

The server sends HTML, then JavaScript makes it interactive: that process is hydration.

<code-group>

```html [Server HTML]
<div id="app">
  <h1>Products</h1>
  <ul>
    <li>Product 1</li>
    <li>Product 2</li>
  </ul>
</div>
```

```ts [Client Hydration]
import { createSSRApp } from 'vue'
import App from './App.vue'

const app = createSSRApp(App)
app.mount('#app')
```

</code-group>

Vue creates the same app that ran on the server, matches components to DOM nodes, and attaches event listeners. If the server HTML and the client's expectations differ, you get a [hydration mismatch](https://vuejs.org/guide/scaling-up/ssr.html).

## Why Hydration Mismatches Break SEO

A user's browser keeps running your JavaScript until they navigate away, so if hydration throws and Vue has to discard nodes and remount, they rarely notice. Googlebot works against a rendering budget instead. Google gives [no official timing guarantee](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) for how long a page waits before its JavaScript runs, only that it can take longer than a few seconds. If Vue is still mid-recovery from a mismatch when that budget runs out, whatever DOM exists at that moment is what gets indexed.

How this plays out:

1. Google requests your page
2. The server sends complete HTML
3. JavaScript starts hydrating
4. Hydration hits a mismatch and throws
5. Vue discards nodes and remounts
6. Googlebot times out mid-recovery
7. Google indexes the broken intermediate state

You lose rankings without an obvious cause, since your own browser never shows you what Googlebot saw.

## Common Causes of Hydration Mismatches

### Browser APIs During SSR

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
import { onMounted, ref } from 'vue'

// Breaks hydration - window doesn't exist on server
const width = window.innerWidth
const theme = localStorage.getItem('theme')
</script>
```

```vue [✅ Good]
<script setup lang="ts">
import { onMounted, ref } from 'vue'

// Only runs on client
const width = ref(0)
const theme = ref('light')

onMounted(() => {
  width.value = window.innerWidth
  theme.value = localStorage.getItem('theme') || 'light'
})
</script>
```

</code-group>

`window`, `document`, and `localStorage` don't exist in [Node.js](https://nodejs.org). Use `onMounted()`, which only runs client-side.

### Inconsistent Data Between Server and Client

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
// Server and client generate different timestamps
const timestamp = Date.now()
</script>

<template>
  <div>{{ timestamp }}</div>
</template>
```

```vue [✅ Good]
<script setup lang="ts">
declare const window: Window & { __INITIAL_STATE__?: { timestamp: number } }

// Serialize state from server, reuse on client
const timestamp = import.meta.env.SSR
  ? Date.now()
  : window.__INITIAL_STATE__?.timestamp
</script>

<template>
  <div>{{ timestamp }}</div>
</template>
```

</code-group>

The server executes once, when it renders the request. The client executes again during hydration. `Date.now()`, `Math.random()`, and API calls that return different data on each run all produce mismatches.

### Third-Party Scripts

<code-group>

```html [❌ Bad]
<!-- Analytics injects content during hydration -->
<script>
  gtag('config', 'GA_ID')
</script>
```

```vue [✅ Good]
<script setup lang="ts">
import { onMounted } from 'vue'

// Load after hydration completes
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>
```

</code-group>

Analytics, chat widgets, and ad scripts that modify the DOM before hydration completes cause mismatches; load them after `onMounted()` instead.

### Invalid HTML Structure

```vue [❌ Bad]
<template>
  <!-- Browser auto-corrects invalid HTML -->
  <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: the server sends one structure, the browser corrects it, and Vue still expects the server version, so hydration fails. See the [Vue SSR guide](https://vuejs.org/guide/scaling-up/ssr.html) for the full list of mismatch causes.

## Debugging Hydration Mismatches

Vue logs mismatches to console in development:

```text
[Vue warn]: Hydration node mismatch:
- Client vnode: div
- Server rendered DOM: span
```

Compare Google's rendered HTML against your own: open Google Search Console → URL Inspection → Test Live URL → View rendered HTML, then compare it to "View Page Source" in your browser. If they differ, you have a hydration problem.

### Test with curl

```bash
# See what Google gets initially
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com

# Should match browser's View Page Source
```

### Suppress What You Can't Fix

Vue 3.5 added [`data-allow-mismatch`](https://vuejs.org/guide/scaling-up/ssr.html) to selectively suppress mismatches you can't avoid:

```vue
<template>
  <!-- Suppress inevitable mismatches -->
  <div data-allow-mismatch="text">
    {{ timestamp }}
  </div>
</template>
```

Use it sparingly: suppression hides the console warning, but Google still indexes whatever mismatched content is there.

## Performance Impact: INP and Hydration

Hydration affects all Core Web Vitals, but INP suffers most.

<tip title="Hydration Blocks Interaction">

Hydration is a CPU-heavy task. While Vue attaches event listeners to your HTML, the browser's main thread is fully blocked.

If a user clicks a button while hydration is running, the browser can't respond until hydration finishes. That delay shows up directly in your INP score.

</tip>

- **LCP**: slow hydration delays largest contentful paint if it depends on JS
- **INP**: heavy hydration creates long tasks (50ms+) that freeze the UI
- **CLS**: mismatches cause layout shifts as Vue repairs the DOM

Target: LCP under 2.5s, INP under 200ms, CLS under 0.1.

Hydration cost scales with page size. [Markus Oberlehner notes](https://markus.oberlehner.net/blog/partial-hydration-concepts-lazy-and-active/) that on big sites with deeply nested HTML, every component needs matching and every event listener needs attaching, which is why hydration gets expensive fast on content-heavy pages.

## Partial Hydration

Hydrate only interactive components; static content stays static.

<code-group>

```vue [❌ Bad - Hydrates Everything]
<script setup lang="ts">
import CommentSection from './CommentSection.vue'
</script>

<template>
  <article>
    <h1>Blog Post</h1>
    <p>Long static content...</p>
    <CommentSection />
  </article>
</template>
```

```vue [✅ Good - Client-Only Comments]
<script setup lang="ts">
import { defineAsyncComponent, onMounted, ref } from 'vue'

const isMounted = ref(false)
onMounted(() => { isMounted.value = true })

const CommentSection = defineAsyncComponent(() =>
  import('./CommentSection.vue')
)
</script>

<template>
  <article>
    <h1>Blog Post</h1>
    <p>Long static content...</p>
    <CommentSection v-if="isMounted" />
  </article>
</template>
```

</code-group>

Lazy hydration defers hydration until it's needed:

```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// Lazy load component - only fetched when rendered
const CommentSection = defineAsyncComponent(() =>
  import('./CommentSection.vue')
)
</script>
```

A couple of tools do this for you:

- [îles](https://iles.pages.dev/) ships zero JS by default and hydrates only the islands you mark as interactive
- Nuxt ships `<Lazy>` component hydration strategies (`hydrate-on-visible`, `hydrate-on-idle`, `hydrate-on-interaction`) as a built-in alternative to third-party lazy-hydration packages
- `<NuxtIsland>` renders server-only components with no client JS at all

## Hydration Best Practices

### Keep State Consistent

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
// Different server vs client - window undefined on server
const isMobile = window.innerWidth < 768
</script>
```

```vue [✅ Good]
<script setup lang="ts">
import { computed } from 'vue'

const isMobile = computed(() => {
  if (!import.meta.env.SSR) {
    return window.innerWidth < 768
  }
  return false // default for SSR
})
</script>
```

</code-group>

### Fetch Data Properly

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const products = ref([])

// Client-only fetch causes empty server HTML
onMounted(() => {
  (async () => {
    products.value = await fetch('/api/products').then(r => r.json())
  })()
})
</script>
```

```vue [✅ Good]
<script setup lang="ts">
declare const window: Window & { __INITIAL_STATE__?: { products: unknown[] } }

// Fetch server-side, transfer state to client
// Use SSR state serialization (vite-ssr, or manual __INITIAL_STATE__)
const products = import.meta.env.SSR
  ? await fetch('/api/products').then(r => r.json())
  : window.__INITIAL_STATE__?.products
</script>
```

</code-group>

### Avoid Browser-Specific Logic

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
// Server crashes - navigator undefined
const userAgent = navigator.userAgent
</script>
```

```vue [✅ Good]
<script setup lang="ts">
// Check environment first
const userAgent = !import.meta.env.SSR ? navigator.userAgent : ''
</script>
```

</code-group>

The same rule covers third-party scripts: defer analytics, ads, and chat widgets to `onMounted()` instead of letting them run during hydration. They don't need to hydrate since they inject markup afterward.

## When Hydration Doesn't Matter

Not every app needs perfect hydration for SEO. Skip these worries for:

- Internal dashboards
- Apps behind authentication
- Admin panels
- Content you don't want indexed

If Google doesn't need to see it, hydration mismatches don't hurt SEO.

## Using Nuxt?

Nuxt inherits Vue's dev-mode hydration warnings, plus framework-level tools for partial hydration:

- [Nuxt Delay Hydration](https://github.com/harlan-zw/nuxt-delay-hydration) defers hydration until the browser is idle or the user interacts
- Built-in `<Lazy>` component hydration strategies (`hydrate-on-visible`, `hydrate-on-idle`, `hydrate-on-interaction`) need no extra dependency
- `<NuxtIsland>` renders server-only components with zero client JS

[Learn more about rendering modes in Nuxt →](/learn-seo/nuxt/routes-and-rendering/rendering)

## Checklist

<checklist id="vue-hydration">

- Server and client HTML match on first render ("View Source" matches "Test Live URL")
- No `window`, `document`, or `localStorage` access outside `onMounted()`
- No `Date.now()`, `Math.random()`, or other non-deterministic values rendered directly in templates
- Third-party scripts load after `onMounted()`, not during initial render
- `data-allow-mismatch` used only for genuinely unavoidable differences
- INP measured under 200ms on hydration-heavy pages specifically

</checklist>
