A VitePress 1.6 date-picker demo works in `vitepress dev`, but `vitepress build` fails with `window is not defined`, even inside `<ClientOnly>`. Why, and how do you fix it?
answer
- dev never prerenders
- ClientOnly waits for mount
- imports still run in Node
- load it after mounting
- a flag for the server build
basics
~20 svitepress dev renders only in the browser, but build prerenders in Node. <ClientOnly> delays rendering until mount, yet the library still touches window when it is imported. Load it after mount with defineClientComponent or a dynamic import.
solid answer
~50 s`vitepress dev` renders pages in the browser only, while `vitepress build` prerenders every page in Node, where `window` does not exist. `<ClientOnly>` renders its slot only after it has mounted in the browser, so it protects against code that runs during a component's setup or render. It cannot help here because the date-picker library reads `window` when its module is evaluated, and a static `import` at the top of the page or demo is evaluated while Node loads the page for prerendering. The fix is to move the import to the client: `defineClientComponent(() => import('./demos/DatePickerDemo.vue'))` from `vitepress` imports the component in the wrapper's mounted hook; a dynamic `import()` inside `onMounted` works for non-component code; and a plugin registered in `enhanceApp` can be loaded with `if (!import.meta.env.SSR) await import(...)`. Reserve `<ClientOnly>` for components that only misbehave while rendering.
code
md · 11 lines<script setup>
import { defineClientComponent } from 'vitepress'
const DatePickerDemo = defineClientComponent(
() => import('./demos/DatePickerDemo.vue'),
)
</script>
# Date picker
<DatePickerDemo />go deeper
Recall that the build prerenders pages in Node, where window does not exist, and that ClientOnly renders its content only in the browser.
Explain why dev hides SSR problems, what ClientOnly does internally, and how defineClientComponent differs from it.
Show how you tell import-time crashes from render-time ones, move imports client-side, and keep the build exercised in CI.
Decide which demos may be client-only, weighing live interactivity against prerendered content, crawlability and layout stability.
## Why dev hides the problem `vitepress dev` serves pages through the Vite dev server and renders them **in the browser**. Nothing runs in Node except the dev server itself, so code that assumes `window`, `document` or `localStorage` works. `vitepress build` is different: it **prerenders every page in Node** with Vue's server renderer, then writes the HTML. Any code that runs during that render, or while the page's modules are loaded for it, runs where browser globals do not exist. That is why a site can work all week in dev and fail at the first build. ## What `<ClientOnly>` actually does `<ClientOnly>` is a tiny built-in component. It keeps a flag that becomes `true` in `onMounted`, and it renders its default slot only when the flag is set. During the build, `onMounted` never runs, so the slot content is not rendered at all. That protects against code inside the wrapped component's **setup or render**: - a component that reads `window.innerWidth` in `setup`; - a custom directive that is not SSR-friendly; - a `<Teleport>` to a target other than `body`, which VitePress cannot prerender. It does **not** protect against code that runs when a module is **imported**. The page's `<script setup>` imports are evaluated when Node loads the page for prerendering, before anything renders. If the date-picker library touches `window` at the top level of its module, the crash happens there, and `<ClientOnly>` around the tag is irrelevant. ## Moving the import to the browser | Tool | Use it for | |---|---| | `defineClientComponent(loader, args?, cb?)` from `vitepress` | a component whose module touches the browser on import | | dynamic `import()` inside `onMounted` | non-component code, such as initialising a chart library | | `if (!import.meta.env.SSR) { await import(...) }` | conditional imports, including inside an async `enhanceApp` | | `<ClientOnly>` | components that are fine to import but not to render on the server | `defineClientComponent` returns a wrapper component. On the server it renders nothing; in the browser it calls the loader in its mounted hook, then renders the loaded component, passing along props and slots given as `h()` arguments, and runs the optional callback once loaded. ## Applying it to the library docs 1. Keep the page free of static imports of the date-picker library. 2. Import the demo through `defineClientComponent(() => import('./demos/DatePickerDemo.vue'))`. 3. If the library also installs a Vue plugin, register it in the theme's `enhanceApp` behind `import.meta.env.SSR`. 4. Run `vitepress build` locally, or in CI on every pull request, so the Node render is exercised before merging. ## Costs of client-only rendering - The demo is missing from the prerendered HTML, so it appears only after the JavaScript loads and is not visible to crawlers. - Reserve a fixed-size container to avoid layout shift when it appears. - Content that differs between server and client outside such wrappers still causes hydration mismatches; that is a Vue SSR concern the wrapper does not solve for you. ## A quick diagnostic Read the stack trace from the build. If the failing frame is inside the library's module scope, the problem is import time: use a client-side import. If it is inside a component's setup or render function, `<ClientOnly>` or moving the code into `onMounted` is enough. ## Where the import hides The static import that breaks the build is not always on the page you are looking at: - **In the theme entry.** A library imported at the top of `.vitepress/theme/index.ts`, for example to register it globally, is loaded for every page, so every page fails to prerender. - **In a shared demo wrapper.** A globally registered `DemoBlock` that imports a helper touching `document` breaks every page that uses it. - **Inside the demo component.** `DatePickerDemo.vue` may be harmless, but its own `import { NDatePicker } from 'nimbus-datepicker'` pulls in the offending module. The fix is the same in each case: move the import behind a client-only boundary at the highest point where it enters, the theme's async `enhanceApp`, a `defineClientComponent` loader or an `onMounted` dynamic import. A good habit for a UI library is to make its own modules import-safe in Node, touching browser globals only inside functions, so the docs, and users' server-rendered apps, never need these workarounds. ## Summary of the rule - Render-time browser access: wrap in `<ClientOnly>` or move into `onMounted`. - Import-time browser access: never import statically; load after mount. - Always verify with `vitepress build`, never only with `vitepress dev`.
- Why does a Teleport to #modal need <ClientOnly> in VitePress when a Teleport to body does not?VitePress's static generation supports teleports to `body` only. A teleport to any other target has no place to go in the prerendered HTML, so the docs recommend wrapping it in `<ClientOnly>` or injecting the markup into the final HTML with the `postRender` build hook.
- What does the user see for a demo loaded with defineClientComponent, and how do you soften it?Nothing from the demo is in the prerendered HTML; it appears after the page's JavaScript runs and the wrapper mounts. Reserve its space with a fixed-height container or a placeholder element so the page does not jump, and keep explanatory text outside the client-only part so it is still prerendered.
ClientOnly is a curtain that stays closed during the dress rehearsal: it hides an actor who would misbehave on stage, but if the actor breaks something just by being brought into the building, the curtain cannot help; you have to bring that actor in only after the doors open.
saying these in an interview costs you the question
- If a page works in vitepress dev, vitepress build will prerender it too.
- Wrapping any component in <ClientOnly> makes its whole module safe for the build.
- VitePress renders pages on a server at request time, so window exists there.
- defineClientComponent imports the component during the server build and hides it.
- Checking typeof window inside the template always prevents hydration problems.