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.
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.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".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.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.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 }).<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.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.test/fixtures/*, with a TZ/LANG matrix, and run it in CI before publishing.A composable with a documented SSR contract, as it would live in a module's runtime directory:
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:
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}`)
}
})
export default defineNuxtPlugin(() => {
const theme = useCookie<'light' | 'dark'>('theme', { default: () => 'light' })
// unhead renders it into the server HTML and hydrates it on the client
useHead({ htmlAttrs: { class: computed(() => `theme-${theme.value}`) } })
})
A server component for output that should never be hydrated:
<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>
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.
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.
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.
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.
runtime/ for Date.now, Math.random, window., document., localStorage and toLocale. For each hit decide: payload (useState), cookie, onMounted, useId, or mode: 'client'.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().useHead({ bodyAttrs }). Diff the server HTML before and after..server.vue component. Compare the client bundle and the network panel; then add one interactive button inside it with nuxt-client.TZ=Europe/Prague."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."
"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."
Diagnosing, step by step
A seven-step workflow from reading the warning to a production build that talks, and the E2E test that turns the manual check into a CI gate.
Memory leaks
Why a Nuxt server leaks where an SPA does not, what a leak actually is, why cross-request state pollution is its security cousin, and why a layer or module author owns the risk for every consuming app.