Hydration errors

Author responsibilities

The rules a layer or module author follows so that nothing they ship can cause a mismatch in a consumer's app, and how they prove it in the playground.

The previous pages were about finding and fixing mismatches. This one is about the role you are interviewing for: when you write the layer and the modules an internal team builds on, the consumers do not get to fix your components, they only get to see the warnings. Interviewers ask "how does a module avoid causing mismatches?" to hear whether you design for SSR by construction or bolt <ClientOnly> on afterwards.

Know

  • Components you ship render deterministically on both sides. No Date.now(), Math.random() or locale formatting in render paths; ids from useId(); browser APIs only in onMounted; every environment-dependent value has an SSR default that is also the first client render.
  • Composables that touch the browser return an SSR-stable value and update after mount. Return null/false during SSR and on the first client render, flip in onMounted, clean up with onScopeDispose, and expose the state (isReady, isSupported) so consumers can render a skeleton. Document it in the README: "returns null during SSR".
  • Browser-only components are marked, not documented. In a module: addComponent({ name, filePath, mode: 'client' }). In a layer: app/components/Name.client.vue. Nuxt wraps both so consumers never write <ClientOnly> themselves. Trade-off: the component has no server HTML and renders only after mount, so template refs need await nextTick() inside onMounted, and you should offer a fallback/skeleton for the space it will occupy.
  • Never touch the DOM from a plugin before mount. Plugins run on both sides; a body class added with document.body.classList exists only on the client. Route <html>/<body> attributes through useHead({ htmlAttrs, bodyAttrs }) so unhead renders them on the server and hydrates them on the client.
  • Heavy, non-interactive output goes into server components or islands. Markdown, syntax highlighting, rendered diagrams: Name.server.vue (or <NuxtIsland>) renders on the server only, ships zero client JS for that subtree and is never hydrated, so it cannot mismatch. Interactive parts inside an island opt in with the nuxt-client attribute (experimental.componentIslands: { selectiveClient: true }).
  • Heavy interactive components defer hydration instead of skipping SSR. <LazyHeavyChart hydrate-on-visible /> and the other hydrate-on-* props keep the server HTML and hydrate later; the HTML must still match when hydration eventually runs, so this is a performance tool, not a mismatch fix.
  • Layers carry extra exposure. Layouts, pages and app.config defaults you ship wrap every page in every consumer app. Theme, locale and auth that the layout renders must come from a cookie or the route, never from localStorage.
  • Ship the hydration E2E test from Diagnosing in the playground and in each test/fixtures/*, with a TZ/LANG matrix, and run it in CI before publishing.

How it works

A composable with a documented SSR contract, as it would live in a module's runtime directory:

src/runtime/composables/useViewportWidth.ts
import { computed, onMounted, onScopeDispose, readonly, shallowRef } from 'vue'

/**
 * Viewport width in CSS pixels.
 * SSR and first client render: `null` (both sides agree, no mismatch).
 * After mount: live value, updated on resize.
 */
export function useViewportWidth() {
  const width = shallowRef<number | null>(null)

  if (import.meta.client) {
    const update = () => { width.value = window.innerWidth }
    onMounted(() => {
      update()
      window.addEventListener('resize', update, { passive: true })
    })
    onScopeDispose(() => window.removeEventListener('resize', update))
  }

  return { width: readonly(width), isReady: computed(() => width.value !== null) }
}

Registering components from a module, with the browser-dependent one marked client-only:

src/module.ts
import { addComponent, createResolver, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'team-ui' },
  setup() {
    const { resolve } = createResolver(import.meta.url)

    // instantiates a browser-only editor library in setup: never render it on the server
    addComponent({
      name: 'TeamRichEditor',
      filePath: resolve('./runtime/components/RichEditor.vue'),
      mode: 'client',
    })

    // SSR-safe: default mode renders on both sides
    addComponent({
      name: 'TeamDataTable',
      filePath: resolve('./runtime/components/DataTable.vue'),
    })
  },
})

In a layer the equivalent is the filename: app/components/TeamRichEditor.client.vue.

Head and body attributes from a plugin, the wrong way and the right way:

export default defineNuxtPlugin(() => {
  const theme = useCookie<'light' | 'dark'>('theme', { default: () => 'light' })
  if (import.meta.client) {
    // exists only in the browser: "Hydration class mismatch on <html>"
    document.documentElement.classList.add(`theme-${theme.value}`)
  }
})

A server component for output that should never be hydrated:

app/components/CodeSample.server.vue
<script setup lang="ts">
import { codeToHtml } from 'shiki'

const props = defineProps<{ code: string, lang: string }>()

// runs on the server only; the client receives finished HTML and no highlighter bundle
const html = await codeToHtml(props.code, { lang: props.lang, theme: 'github-dark' })
</script>

<template>
  <div class="code-sample" v-html="html" />
</template>
Gotcha· mode: 'client' moves the cost, it does not remove it

A client-only component has no server HTML: the consumer's page ships a hole that fills after mount, which is a layout shift and a missing element for crawlers. Use it for components that genuinely cannot render in Node (editors, maps, canvas), give them a sized fallback, and keep everything else SSR-capable.

Gotcha· Islands are for content, not controls

An island's props must be serialisable, its slots are rendered by the parent, and on client-side navigation each island is a server round-trip. Great for a rendered markdown body or a highlighted code block; wrong for a form.

Gotcha· A ClientOnly in a shared layout is a policy decision

Wrapping a header or sidebar in <ClientOnly> inside a layer's layout deletes server rendering for every page in every consumer, and the tree-shaken default slot's CSS is no longer inlined. If a layout element depends on the user, feed it from a cookie and render it on both sides.

Verify before the interview:

Two fast-moving details: experimental.componentIslands defaulting to 'auto' (enabled when a .server.vue or .island.vue file is detected) and the exact list of lazy-hydration props (hydrate-on-visible, hydrate-on-idle, hydrate-on-interaction, hydrate-on-media-query, hydrate-after, hydrate-when, hydrate-never) behind experimental.lazyHydration. Confirm both on the components page before the interview.

docs ↗

Exercise

Exercise
  • Grep a module's runtime/ for Date.now, Math.random, window., document., localStorage and toLocale. For each hit decide: payload (useState), cookie, onMounted, useId, or mode: 'client'.
  • Convert one browser-dependent component to mode: 'client' (module) or .client.vue (layer). Confirm the playground page needs no <ClientOnly>, then read the component's template ref inside onMounted with and without await nextTick().
  • Move a body class from a plugin's DOM mutation to useHead({ bodyAttrs }). Diff the server HTML before and after.
  • Move markdown rendering into a .server.vue component. Compare the client bundle and the network panel; then add one interactive button inside it with nuxt-client.
  • Add the hydration E2E test to the playground and to every fixture; run the suite once with TZ=Europe/Prague.

Be able to say

Be able to say· How does a module or layer you ship avoid causing hydration mismatches in consumers' apps?

"I design for SSR by construction rather than wrapping things in <ClientOnly> afterwards. Every component renders the same on both sides: no timestamps or random values in render paths, ids from useId, browser APIs only in onMounted with an SSR default that is also the first client render. Composables that need the browser return a stable value during SSR, update after mount and say so in the README. Components that genuinely cannot run in Node are registered with mode: 'client' or as .client.vue, so Nuxt wraps them and the consumer never has to know. Head and body attributes go through useHead, never the DOM from a plugin. Heavy static output such as highlighted code lives in a server component that is never hydrated. And the playground ships an E2E test that fails on any console message matching Hydration, so a regression cannot reach a consumer."

Be able to say· When is <ClientOnly> the wrong fix?

"When the content matters, or when it could be made deterministic. It removes server rendering for the whole subtree, so a crawler and the LCP measurement never see it, the default slot's CSS is no longer inlined, and the user gets a hole that fills after mount. Most of the time the mismatch has a real fix: useState for values computed once, useCookie for preferences the server should know, useAsyncData for data, useId for ids, useHead for body attributes. I reserve <ClientOnly> and mode: 'client' for things that cannot exist in Node, such as a canvas map or a rich-text editor, and I give them a sized fallback. In a shared layout I treat a <ClientOnly> as a policy decision that affects every consumer, not a quick fix."