---
title: "Hydration Mismatches and SEO in Vue"
description: "Debug Vue hydration mismatches by comparing server and client markup. Keep initial state consistent and distinguish lazy loading from delayed hydration."
canonical_url: "https://nuxtseo.com/learn-seo/vue/spa/hydration"
last_updated: "2026-10-05"
---

> Index of every page on this site: [llms.txt](https://nuxtseo.com/llms.txt)

::key-takeaways
- A mismatch means server markup differs from the client render. Vue attempts recovery; inspect warnings and actual content before inferring an indexing problem
- Hydration work can delay interactions. Pure client-rendered apps mount instead; measure which tasks affect your page
- Inconsistent initial data and browser-corrected invalid HTML can cause mismatches. Browser-only API errors can prevent server rendering before hydration begins
- Use `data-allow-mismatch`{lang="html"} sparingly, for genuinely unavoidable differences like timestamps
::

Hydration attaches a client Vue app to server-rendered HTML. When the two renders differ, Vue reports a mismatch and attempts to recover. Recovery can replace nodes and add work. Check the page's actual output and interactions rather than assuming a Googlebot timeout or ranking loss.

## 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')
```
::

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

## Inspect Hydration Mismatches

Content that differs between the response and rendered page can confuse debugging and change what users or a rendering client receives. Vue's recovery behavior does not establish which state Google indexed.

Google's documented rendering queue delay is not a fixed hydration deadline. Inspect console warnings, required resources, initial HTML, and rendered output. Differences alone do not prove hydration failed; client updates can be intentional.

## Common Causes of Hydration Mismatches

### Browser APIs During SSR

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

// Fails during Node SSR: window and localStorage are browser APIs
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>
```
::

`window`{lang="js"} and `document`{lang="js"} are browser DOM APIs. Some Node versions expose globals such as [`navigator` and `localStorage`](https://nodejs.org/api/globals.html), but these do not represent a visiting browser's state. Read browser state in `onMounted()`{lang="ts"}, which does not run during server rendering.

### Inconsistent Data Between Server and Client

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

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

```vue [✅ Good]
<script setup lang="ts">
// Component excerpt: the app must pass the same timestamp on server and client
const props = defineProps<{ timestamp: number }>()
</script>

<template>
  <div>{{ props.timestamp }}</div>
</template>
```
::

The prop does not serialize itself. Supply identical initial data through your framework's state-transfer mechanism. For a bounded example, pass the same numeric timestamp in a route's props on both sides. For request-time data, use the framework's documented payload handling, rather than declaring an undefined `window.__INITIAL_STATE__` field.

The server executes during an SSR request or a prerender build. The client executes again during hydration. `Date.now()`{lang="ts"}, `Math.random()`{lang="ts"}, and API calls that return different data on each run can produce mismatches when their values appear in initial markup.

### Third-Party Scripts

Scripts that change Vue-owned markup before hydration can cause mismatches. Isolate their DOM ownership or initialize them after the relevant component mounts. Analytics can run without changing Vue-owned DOM. Loading a script later does not configure its analytics API or guarantee lower interaction cost; check the script's actual behavior.

### 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> -->
<!-- Client rendering can report a hydration mismatch -->
```

Browsers silently fix invalid HTML: the server sends one structure, the browser corrects it, and Vue still expects the server version, so the client can encounter a mismatch and recover. 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:

```
[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. A difference can come from intentional client updates, resource failures, or mismatches. Use Vue warnings and a reproducible browser check to identify the cause.

### Test with curl

```bash
# Inspect an HTTP response under this user-agent string
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com

# This does not emulate Google rendering or verify Googlebot identity
```

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

This template excerpt assumes an app-owned timestamp value. Suppress only a difference you have examined:

```vue
<template>
  <!-- Suppress an accepted text difference -->
  <div data-allow-mismatch="text">
    {{ timestamp }}
  </div>
</template>
```

Use it sparingly: suppression changes mismatch handling for the named category. It does not repair inconsistent data or prove an indexing outcome.

## Performance Impact: INP and Hydration

Hydration can contribute to main-thread work. Its effect depends on the page and when users interact.

::tip{title="Hydration Blocks Interaction"}
Hydration performs work on the main thread. A long task prevents other main-thread work from running until it yields.

An interaction during a long task may wait for input processing or a paint. Measure that interaction to identify its contribution to INP.
::

- **LCP**: JavaScript-dependent content may paint later
- **INP**: long tasks can delay interaction handling or paint
- **CLS**: DOM repairs or client updates can cause visible layout shifts

[Good thresholds](https://web.dev/articles/vitals) are LCP at most 2.5s, INP at most 200ms, and CLS at most 0.1, assessed at the 75th percentile.

Component work, listeners, data processing, and DOM size can affect hydration cost. Profile a representative page instead of assuming one component count predicts performance.

## Partial Hydration

Partial hydration leaves selected server-rendered content without client hydration. Delayed hydration changes when a rendered component becomes interactive. Client-only mounting creates a component later. These are different behaviors.

::code-group
```vue [Default hydration]
<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 [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>
```
::

The preceding excerpt assumes an app-owned `CommentSection.vue`. It mounts comments only after the parent mounts; the parent still hydrates. It does not implement partial hydration of existing server-rendered comments.

An async import alone controls code loading:

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

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

Vue 3.5+ supports [explicit hydration strategies](https://vuejs.org/guide/components/async.html#lazy-hydration) for async components, such as `hydrateOnVisible` and `hydrateOnInteraction`. Choose a strategy based on when the component must become interactive. Test the triggering interaction and page content. A dynamic import alone does not set that strategy.

## 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 { onMounted, ref } from 'vue'

// Same initial state on server and client
const isMobile = ref(false)
onMounted(() => {
  isMobile.value = window.innerWidth < 768
})
</script>

<template>
  <p>{{ isMobile ? 'mobile' : 'desktop' }}</p>
</template>
```
::

Checking `import.meta.env.SSR` prevents a server API error but does not make different initial markup consistent. The example starts with the same value, then applies a browser-only update after mounting. Prefer CSS media queries when the difference is visual layout.

### Fetch Data Properly

Load public page data during server rendering or generation when it belongs in initial HTML. Reuse the same payload during hydration. A relative browser fetch URL is not a complete Node server data loader, and an undeclared global payload is not state transfer.

Follow your framework's documented data APIs or vite-ssg's [initial-state handling](https://github.com/antfu/vite-ssg#initial-state). The [prerendering example](https://nuxtseo.com/learn-seo/vue/spa/prerendering) provides a complete route/build setup. Add your application-owned loader through its supported pipeline and verify the generated data and hydrated output.

### Avoid Browser-Specific Logic

::code-group
```vue [❌ Bad]
<script setup lang="ts">
// A server navigator, if available, does not describe the visiting browser
const userAgent = navigator.userAgent
</script>
```

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

const userAgent = ref('')
onMounted(() => {
  userAgent.value = navigator.userAgent
})
</script>
```
::

Keep browser-specific effects out of server execution. If a script alters Vue-owned markup, also control when it runs and which container it owns.

## When Hydration Doesn't Matter

Not every app needs perfect hydration for SEO. These views may not need public indexing, but still need correct content and working interactions:

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

Private views can be outside your SEO scope. Mismatches can still affect users, state, and interaction behavior.

## Using Nuxt?

Nuxt inherits Vue's hydration behavior. Its [lazy component strategies](https://nuxt.com/docs/4.x/directory-structure/app/components#delayed-or-lazy-hydration) use components such as `<LazyMyComponent hydrate-on-visible />`{lang="html"}. The generic `<Lazy>`{lang="html"} name is not the API.

[Server components and islands](https://nuxt.com/docs/4.x/guide/concepts/server-components) have separate configuration and context limits. Check the current experimental requirements before treating them as a replacement for a client component.

[Learn more about rendering modes in Nuxt →](https://nuxtseo.com/learn-seo/nuxt/routes-and-rendering/rendering)

## Checklist

::checklist{#vue-hydration}
- Initial client render matches server markup; intentional updates are checked separately from mismatch warnings
- Browser-only effects and imports stay out of server execution
- No `Date.now()`{lang="ts"}, `Math.random()`{lang="ts"}, or other non-deterministic values rendered directly in templates
- Third-party scripts do not alter Vue-owned markup before its hydration
- `data-allow-mismatch`{lang="html"} used only for genuinely unavoidable differences
- Measure interaction costs; assess field INP at the 75th percentile rather than claiming a lab pass
::

## Sitemap

See the full [sitemap](https://nuxtseo.com/sitemap.md) for all pages.
