What Breaks Under SSR
Browser globals, hydration mismatches and module-level state all behave differently once components render on a server.
<ClientOnly><chart-canvas /><template #fallback>Loading</template></ClientOnly>Server rendering runs your components in Node. Three categories of code that work in a browser stop working there, and each fails in its own way.
Browser globals
There is no window, document or localStorage on the server, so touching one during setup throws. Move the access into onMounted, which only ever runs in the browser. For a whole branch of logic, Nuxt replaces import.meta.client and import.meta.server with constants at build time, and plain Vite exposes import.meta.env.SSR.
Hydration mismatches
Hydration expects the first client render to produce the same markup the server sent. The documented causes are invalid HTML nesting that the browser silently repairs, such as a <div> inside a <p>; random values; and anything derived from the local clock or timezone. Vue 3.5 added the data-allow-mismatch attribute to silence a mismatch you cannot avoid, with values text, children, class, style and attribute.
Nuxt's <ClientOnly> skips server rendering for its default slot. Give it something to show in the meantime through the #fallback slot, or the placeholder and placeholderTag props.
State that leaks
On the server, a module is loaded once and reused for every request. A ref at module scope is therefore shared by every visitor at once. In Nuxt, useState(key, init) creates state that is per request and serialised into the payload. In plain Vue, build a fresh store inside the per-request createSSRApp factory and hand it down with app.provide.
Example
<!-- app/components/ViewportWidth.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const width = ref<number | null>(null)
function measure() {
width.value = window.innerWidth
}
onMounted(() => {
measure()
window.addEventListener('resize', measure)
})
onUnmounted(() => {
window.removeEventListener('resize', measure)
})
</script>
<template>
<p>
<span v-if="width === null">Measuring viewport</span>
<span v-else>{{ width }}px wide</span>
</p>
</template>
<style scoped>
p {
margin: 0;
}
</style>When to use it
- Fixing a page that renders in dev but crashes with "window is not defined" as soon as SSR is on.
- Tracking down a hydration warning caused by a date formatted in the server's timezone.
- Stopping one user's cart or session from appearing in another user's server-rendered page.
More examples
Per-request state instead of a module ref
The commented-out line is the bug: in the browser one module instance per tab is harmless, but on a server it is one instance for everyone, so one visitor's cart appears in another's page. `useState` scopes the value to the request and serialises it into the payload, so the browser hydrates with the same contents. Its value must be JSON-serialisable; a class instance raises "Cannot stringify arbitrary non-POJOs".
// app/composables/useCart.ts
// Wrong: one array, shared by every request this server process handles.
// const items = ref<string[]>([])
export function useCart() {
const items = useState<string[]>('cart', () => [])
function add(sku: string) {
items.value = [...items.value, sku]
}
return { items, add }
}
// const { items, add } = useCart(); add('MUG-1')
// items.value
// Output: ['MUG-1']ClientOnly around a browser-only library
`<map-canvas />` is a local component (`app/components/MapCanvas.vue`) that reaches for `document` as it initialises a mapping library, so it must not render on the server. The `#fallback` slot is what the server sends and what the user sees until the client mounts. The shorthand for a plain text fallback is the prop pair `placeholder="Loading map" placeholder-tag="p"`, which produces the same `<p>Loading map</p>`.
<!-- app/components/MapPanel.vue -->
<script setup lang="ts">
const props = defineProps<{ title: string }>()
</script>
<template>
<section>
<h2>{{ props.title }}</h2>
<ClientOnly>
<map-canvas />
<template #fallback>
<p>Loading map</p>
</template>
</ClientOnly>
</section>
</template>
<style scoped>
section {
display: grid;
gap: 0.75rem;
}
</style>Allowing a mismatch you cannot remove
The text depends on when it is rendered, so the server's string and the client's string differ by design. `data-allow-mismatch="text"` (Vue 3.5) tells Vue to accept a difference in this element's text and keep the client value, without the console warning. Scope it as narrowly as possible: the bare attribute allows every kind of mismatch on that element, which hides real bugs.
<!-- app/components/RelativeTime.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ iso: string }>()
const relative = computed(() => {
const days = Math.round(
(Date.parse(props.iso) - Date.now()) / 86_400_000,
)
return new Intl.RelativeTimeFormat('en').format(days, 'day')
})
</script>
<template>
<time :datetime="props.iso" data-allow-mismatch="text">
{{ relative }}
</time>
</template>
<style scoped>
time {
color: inherit;
}
</style>
Discussion