skip to content

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?

level: middleimportance: should knowfreq 42%

answer

  1. default slot mounts in the browser
  2. fallback is rendered on both sides
  3. swap happens after onMounted
  4. slot content left out of server build
  5. the .client.vue suffix alternative

basics

~20 s

Nuxt 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
vue
<!-- 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

for a junior

Recall that <ClientOnly> renders its content only in the browser and shows a fallback until then.

for a middle

Explain the sequence: fallback on the server, the same fallback during hydration, then the slot after mount, and why that avoids a mismatch.

for a senior

Pick the narrowest tool among ClientOnly, .client.vue and route-level ssr: false, and design fallbacks that prevent layout shift and keep content indexable.

for a principal

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