Hydration and Islands
When a Nuxt page loads via SSR, the server sends fully rendered HTML. The browser displays it immediately — users see content before JavaScript finishes loading. Then Vue takes over, attaching event listeners and making the page interactive. This process is hydration, and it is one of the most misunderstood parts of working with SSR frameworks.
How Hydration Works
The server renders your component tree to an HTML string. That string gets sent to the browser along with a serialized copy of your application state (the payload). When Vue's client-side JavaScript loads, it does not re-render the page from scratch. Instead, it walks the existing DOM, matches it against what the client-side render would produce, and attaches event listeners to the existing elements.
<!-- pages/index.vue -->
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts')
</script>
<template>
<div>
<h1>Latest Posts</h1>
<ul>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="`/posts/${post.slug}`">
{{ post.title }}
</NuxtLink>
</li>
</ul>
</div>
</template>
During SSR, useFetch runs on the server and the response is serialized into the Nuxt payload. On the client, useFetch reads from the payload instead of making another network request. The HTML is already in the DOM. Vue just hydrates it — no flash, no re-fetch, no layout shift.
Hydration Mismatches
A hydration mismatch happens when the HTML the server sent does not match what the client tries to render. Vue will log a warning and attempt to patch the DOM, but the result can be broken layouts, duplicated content, or missing event listeners.
The most common causes:
Date and Time Rendering
The server and the browser can be in different timezones.
<!-- This WILL cause a hydration mismatch -->
<script setup lang="ts">
const now = new Date().toLocaleString()
</script>
<template>
<p>Current time: {{ now }}</p>
</template>
The server renders "4/24/2026, 2:30:00 PM" in UTC. The browser renders "4/24/2026, 10:30:00 AM" in Eastern time. Mismatch.
The fix: use <ClientOnly> for time-sensitive content, or format dates in a timezone-agnostic way during SSR and update on the client.
<script setup lang="ts">
const now = ref('')
onMounted(() => {
now.value = new Date().toLocaleString()
})
</script>
<template>
<p>Current time: {{ now || 'Loading...' }}</p>
</template>
Browser-Only APIs
window, document, localStorage, and navigator do not exist on the server. Accessing them during SSR crashes or produces different output.
<script setup lang="ts">
// This crashes on the server
// const width = window.innerWidth
// Instead, check the environment
const width = ref(1024) // sensible default for SSR
onMounted(() => {
width.value = window.innerWidth
})
</script>
<template>
<div :class="width < 768 ? 'mobile' : 'desktop'">
<slot />
</div>
</template>
Random Values and IDs
<!-- Mismatch: server and client generate different IDs -->
<script setup lang="ts">
// const id = Math.random().toString(36).slice(2)
// Use useId() instead — it's deterministic across server/client
const id = useId()
</script>
<template>
<label :for="id">Email</label>
<input :id="id" type="email" />
</template>
Nuxt provides useId() specifically to generate deterministic IDs that match on server and client.
Debugging Hydration Mismatches
In development, Vue logs hydration warnings to the console. But they can be cryptic. Enable detailed hydration mismatch logging:
// nuxt.config.ts
export default defineNuxtConfig({
debug: true,
vue: {
config: {
// Shows detailed info about what mismatched
warnRecursiveComputed: true,
},
},
})
A practical debugging approach: open DevTools, view the page source (Ctrl+U) to see what the server sent, then compare it with the rendered DOM in the Elements panel. The difference is your mismatch.
ClientOnly Component
<ClientOnly> is the escape hatch. Anything inside it renders only in the browser, never on the server.
<template>
<div>
<h1>Dashboard</h1>
<!-- This chart library doesn't support SSR -->
<ClientOnly>
<ApexChart :options="chartOptions" :series="series" />
<template #fallback>
<div class="h-64 bg-gray-100 animate-pulse rounded" />
</template>
</ClientOnly>
</div>
</template>
The #fallback slot provides placeholder content during SSR. Without it, the space is empty until the client renders the component, causing a layout shift.
Use <ClientOnly> for third-party libraries that touch the DOM directly (chart libraries, rich text editors, map widgets), anything using browser APIs, and content that intentionally differs between server and client.
<script setup lang="ts">
const theme = ref('light')
onMounted(() => {
theme.value = localStorage.getItem('theme') || 'light'
})
</script>
<template>
<ClientOnly>
<ThemeToggle v-model="theme" />
<template #fallback>
<div class="w-8 h-8" /> <!-- placeholder so layout doesn't jump -->
</template>
</ClientOnly>
</template>
You can also use the .client.vue suffix to make an entire component client-only:
components/
MyChart.client.vue <!-- only rendered on the client -->
MyChart.server.vue <!-- only rendered on the server (optional fallback) -->
Nuxt Islands
Islands architecture takes a different approach to hydration. Instead of hydrating the entire page, only specific interactive "islands" get hydrated. The rest stays as static HTML with zero JavaScript.
Nuxt's experimental islands feature lets you mark components as server-only, sending no JavaScript for them to the client.
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
componentIslands: true,
},
})
Create a server component with the .server.vue suffix:
<!-- components/ProductCard.server.vue -->
<script setup lang="ts">
const props = defineProps<{
productId: string
}>()
// This fetch runs on the server only — no client JS shipped
const { data: product } = await useFetch(`/api/products/${props.productId}`)
</script>
<template>
<div class="product-card">
<img :src="product?.image" :alt="product?.name" />
<h3>{{ product?.name }}</h3>
<p>{{ product?.description }}</p>
<span class="price">{{ product?.price }}</span>
</div>
</template>
Use it with the NuxtIsland component or just import it normally:
<!-- pages/products.vue -->
<template>
<div class="grid grid-cols-3 gap-4">
<ProductCard
v-for="id in productIds"
:key="id"
:product-id="id"
/>
</div>
</template>
These server components ship zero JavaScript. The HTML is rendered on the server and sent as static markup. This is ideal for content-heavy components that do not need interactivity — product cards in a listing, article bodies, footer sections.
Lazy Hydration
Sometimes a component needs to be interactive eventually, but not immediately. Lazy hydration defers the hydration of a component until a trigger fires — the component enters the viewport, the user interacts with it, or the browser is idle.
<script setup lang="ts">
// Use defineAsyncComponent with a custom hydration strategy
import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle } from 'vue'
const HeavyComments = defineAsyncComponent({
loader: () => import('./HeavyComments.vue'),
hydrate: hydrateOnVisible(),
})
const Analytics = defineAsyncComponent({
loader: () => import('./AnalyticsWidget.vue'),
hydrate: hydrateOnIdle(),
})
</script>
<template>
<main>
<article>
<!-- Article content renders and hydrates immediately -->
<ArticleBody :content="article.body" />
</article>
<!-- Comments hydrate when scrolled into view -->
<HeavyComments :post-id="article.id" />
<!-- Analytics widget hydrates when browser is idle -->
<Analytics />
</main>
</template>
Nuxt also provides the LazyXxx prefix for auto-imported components, which lazy-loads the component code (not the same as lazy hydration, but reduces initial bundle size):
<template>
<!-- Component code is lazy loaded (code-split) -->
<LazyCommentsSection v-if="showComments" :post-id="post.id" />
</template>
For true lazy hydration in Nuxt, combine the approaches:
<script setup lang="ts">
import { hydrateOnVisible } from 'vue'
const LazyReviews = defineAsyncComponent({
loader: () => import('~/components/ReviewsList.vue'),
hydrate: hydrateOnVisible({ rootMargin: '200px' }),
})
</script>
<template>
<div>
<ProductDetails :product="product" />
<!-- Reviews hydrate 200px before they scroll into view -->
<LazyReviews :product-id="product.id" />
</div>
</template>
Common Pitfalls
Wrapping everything in ClientOnly to "fix" hydration. This defeats the purpose of SSR. Your pages become empty shells until JavaScript loads. Instead, fix the root cause of the mismatch.
Not providing fallback content for ClientOnly. Without #fallback, you get layout shifts when client content appears. Always provide a placeholder that matches the expected dimensions.
Using v-if="mounted" as a universal escape hatch. Teams sometimes create a mounted ref and wrap browser-specific code in v-if="mounted". This works but is a code smell at scale. If you find yourself doing this everywhere, reconsider your component design.
Assuming server components are free. Server-rendered islands still require a server roundtrip. If you have 50 server components on a page, that is 50 server requests on client-side navigation. Batch where possible.
Ignoring payload size. The serialized state from useFetch and useAsyncData travels in the HTML as a <script> tag. If you fetch a massive dataset on the server, that entire payload bloats your HTML. Select only the fields you need.
Key Takeaways
- Hydration connects server-rendered HTML with client-side Vue — it does not re-render the page.
- Hydration mismatches occur when server and client output differ; common causes include dates, browser APIs, and random values.
- Use
<ClientOnly>with#fallbackfor browser-only components, not as a blanket fix for mismatches. - Server components (
.server.vue) ship zero JavaScript and are ideal for static, content-heavy parts of the page. - Lazy hydration with
hydrateOnVisibleandhydrateOnIdlereduces initial JavaScript work, improving time-to-interactive.