In Nuxt 4, what does <ClientOnly> render on the server and during hydration, and when is it the right tool for a browser-only widget?
answer
- default slot mounts in the browser
- fallback is rendered on both sides
- swap happens after onMounted
- slot content left out of server build
- the .client.vue suffix alternative
basics
~20 sNuxt 4's <ClientOnly> renders its fallback (the #fallback slot, or fallback text in a span) on the server and again during hydration, then swaps in its default slot after mounting. It suits a browser-only widget inside an otherwise server-rendered page.
solid answer
~40 s`<ClientOnly>` is Nuxt's built-in wrapper for content that can only run in the browser. On the server it renders the fallback: the `#fallback` slot, or the `fallback` text inside a `fallbackTag` element, a `<span>` by default. During hydration it renders that same fallback, so the markup matches, and only after `onMounted` does it render the default slot. The slot's components are also left out of the server build. On the product page I'd wrap a WebGL 3D viewer in it and give it a fallback of the same size to avoid layout shift, while the rest of the page, price and description included, stays server-rendered and indexable. For a whole browser-only section I'd use `ssr: false` in `routeRules`; for a single component, a `.client.vue` suffix achieves the same thing.
code
vue · 13 lines<!-- app/pages/products/[id].vue (excerpt) -->
<template>
<main>
<h1>{{ product.name }}</h1>
<ClientOnly>
<ProductViewer3d :model-url="product.modelUrl" />
<template #fallback>
<img :src="product.imageUrl" width="640" height="480" alt="">
</template>
</ClientOnly>
<p>{{ product.description }}</p>
</main>
</template>go deeper
Recall that <ClientOnly> renders its content only in the browser and shows a fallback until then.
Explain the sequence: fallback on the server, the same fallback during hydration, then the slot after mount, and why that avoids a mismatch.
Pick the narrowest tool among ClientOnly, .client.vue and route-level ssr: false, and design fallbacks that prevent layout shift and keep content indexable.
Set a team convention for browser-only dependencies, so client-only code stays isolated and SSR coverage does not erode page by page.
## What <ClientOnly> is for In a server-rendered Nuxt 4 page, every component runs twice: once on the server to produce HTML and again in the browser to hydrate it. Some components cannot run on the server at all. A WebGL product viewer needs a canvas and `window`, and a chart library may touch the DOM when it initialises. **`<ClientOnly>`** is Nuxt's built-in component for this: its **default slot** is rendered only in the browser, after mounting. ## The rendering sequence 1. **Server render:** `<ClientOnly>` outputs its **fallback**, never the slot. The slot's components are tree-shaken out of the server build. 2. **Hydration:** in the browser, `<ClientOnly>` first renders the **same fallback**, because it is not mounted yet. The server HTML and the first client render therefore agree, and no hydration mismatch arises. 3. **After mount:** an `onMounted` hook flips an internal flag, and `<ClientOnly>` re-renders with the default slot. Only now do the viewer's own lifecycle hooks run. 4. **Visible swap:** the fallback is replaced by the real content, which can shift layout if the two differ in size. ## Why not a v-if on import.meta.client? A tempting shortcut is `<ProductViewer3d v-if="isClient" />` with a flag derived from `import.meta.client`. It breaks exactly where `<ClientOnly>` does not: - on the server the flag is false, so the viewer is missing from the HTML; - during hydration the flag is already true, so the browser's first render **includes** the viewer; - the two renders disagree, which Vue reports as a **hydration mismatch** and patches up at runtime. `<ClientOnly>` avoids that by waiting for its own `onMounted` before switching, so the first client render matches the server's. A flag set to `true` inside `onMounted` reproduces the same safe sequence by hand; `<ClientOnly>` packages it, together with fallbacks and server tree-shaking. ## Props and slots | API | Effect on the server and before mount | |---|---| | `#fallback` slot | renders this markup | | `fallback` (alias `placeholder`) | renders this text inside the fallback tag | | `fallbackTag` (alias `placeholderTag`) | the tag for that text; `span` by default | | none of the above | an empty `span` | The `#fallback` slot takes precedence over the `fallback` prop. ## The product-page case The product page is server-rendered for SEO and fast first paint, and it has one browser-only piece, the 3D viewer: - wrap the viewer in `<ClientOnly>`; - give the `#fallback` slot a static product image with the viewer's dimensions, so crawlers and slow devices see something meaningful and the layout does not jump; - keep price, description and reviews outside, so they stay in the server HTML. ## Alternatives, and when each fits | Tool | Scope | Server output | |---|---|---| | `<ClientOnly>` | any markup in a template | fallback | | `Viewer.client.vue` suffix | one component, everywhere it is used | a placeholder | | `routeRules` `{ ssr: false }` | whole routes | an app shell | | `import.meta.client` or `onMounted` | a few lines of logic | normal render | Prefer the narrowest tool. Making the whole product route `ssr: false` for one widget would throw away the indexable content the page exists for. ## Costs and traps - **No SEO for the slot.** Whatever sits inside is absent from the server HTML. - **Styles may not be inlined.** The docs warn that CSS used only by components in the slot may not be inlined into the initial HTML, so the content can appear unstyled for a moment. - **Late refs.** Elements inside exist only after mount; the docs show watching a `useTemplateRef` ref instead of reading it in `onMounted` of the parent. - **Side-effectful imports still run.** Tree-shaking removes the slot's components from the server build, but a library with top-level side effects imported by the page itself still executes on the server, so import it only from the client-side component. - **It is not lazy hydration.** `<ClientOnly>` skips server rendering entirely; deferring when server-rendered HTML hydrates is a separate Vue feature.
- How does a Viewer.client.vue component differ from wrapping Viewer.vue in <ClientOnly>?The `.client` suffix makes the component client-only wherever it is used, with no wrapper at each call site; the server renders a placeholder in its place and the component renders after mounting. `<ClientOnly>` is chosen per usage and lets you supply a fallback. The suffix fits a component that can never run on the server; the wrapper fits one spot on one page.
- Why does the parent's onMounted not see the element inside <ClientOnly>?The parent mounts while `<ClientOnly>` is still showing its fallback. The slot renders only after `<ClientOnly>`'s own `onMounted` flips its flag and it re-renders. The Nuxt docs therefore watch a `useTemplateRef` ref with `{ once: true }`, which fires when the element actually appears.
saying these in an interview costs you the question
- ClientOnly renders its slot on the server but hides it with CSS
- The server output is empty, so hydration must skip the spot
- Wrapping content in ClientOnly makes it better for SEO
- Putting ssr: false on the whole page is the fix for one widget
- ClientOnly defers hydration of server-rendered HTML
- The parent's onMounted can read refs inside the ClientOnly slot