SSR
The package is safe for server rendering as a whole. The caveats are not about safety but about what exactly arrives from the server.
Machine-translated, not yet reviewed. Read the original
Nuxt, vite-ssr or your own @vue/server-renderer — the short answer is the
same: the whole package renders on the server and hydrates without
mismatches. What follows are the caveats about what exactly arrives from the
server and where a plugin is required.
The teleport switches on after hydration
Panels, popovers and overlays live in body, but they do not get there at once.
The contract is the same for every component: the teleport is off on the server
and on the first client render, and switches on in onMounted. A disabled
teleport means “render in place”, so the server HTML and the first client render
match.
What follows for the application:
- panels arrive from the server — inside their component’s markup and hidden, so there is no flash of an expanded list;
- teleport anchors still have to be inserted into the markup, otherwise Vue will not find its attachment point;
- client-only wrappers are needed by none of the package’s components.
The exception is the contents of modals. GrModal and everything built on it
— GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette,
GrImageViewer, GrDrawer — does not hand its content to the server at all,
even while open. The practical conclusion: it reaches neither the first screen
nor search results, and hydration mismatches inside it are impossible in
principle.
If you are writing a component with a teleport of your own, take
usePortalTarget() and its enabled rather than
typeof window !== 'undefined'. The environment check looks sufficient, but it
is exactly what creates the mismatch: it gives different results on the server
and on the first client render.
Composables: which need a plugin
| API | On the server |
|---|---|
useTheme() | Reading is fine, writing needs granularityThemePlugin. What leaks is the mutation: module state is shared across every request |
useToast() | Requires granularityToastPlugin; without it, it throws with an explanation |
useDialogService() | The composable itself is safe, any of its methods throws on the server: it mounts a host into document.body |
useAnnouncer() | Safe, an announcement is a no-op: a live region is a node of the document, not transferable state |
useGrConfig(), useGrFormFieldContext() | Pure, they do not touch the DOM |
useOverlayLayer() | Safe: on the server no layer is put on the stack at all |
vClickOutside, vHotkey, vLoading | They work in mount hooks and are never called on the server |
The prohibitions all share one rule: module state on the server is state shared by every request, and one user’s choice would leave in another’s response. The plugin gives each application its own.
ClientOnly — who needs it
None of the package’s components, and wrapping them is harmful: the server markup
disappears and the point of SSR with it. The wrapper is for your code that
reads the environment straight in the template: the window width, navigator,
localStorage, the time in the local zone.
It does not cover imperative APIs at all — they are not rendered, so it is the call that has to be hidden, not the markup:
if (typeof window !== 'undefined')
await useDialogService().confirm({ title: 'Delete?' })A theme without a flash
The server does not know which theme the user picked. The solution is an inline
script in <head> before the first render:
<script>
try {
var t = localStorage.getItem('gr-theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.dataset.theme = t
}
catch {}
</script>The key gr-theme and the data-theme attribute are the same contract
useTheme() uses. If the theme is chosen by the server — from a cookie or a
profile — install granularityThemePlugin: a plain SPA does not need it, the
module singleton is enough there.
CSS has no effect on server rendering: it is static. Only one thing is critical — the theme has to apply before the first paint, otherwise a user with a dark theme sees a flash of the light one.
Rules for a component of your own
- The DOM only in
onMounted/onBeforeUnmountand in handlers. Not a single access in the body ofsetupor at module level. - If you need
documentorwindowoutside a hook, guard withtypeof window === 'undefined'rather than withtry/catch. - If you teleport, take
enabledfromusePortalTarget(), not an environment check. - Browser APIs that may be missing even in a browser (
ResizeObserver,matchMedia) have to be checked for existence. - DOM identifiers come from
useId()only. Not an instance counter (on the server it grows between requests, on the client it starts from zero) and not a random number: the mismatch arrives silently — throughid,aria-controls,aria-labelledby. - A value that depends on the environment must not be used in the first render: a
refwith the server value and a refinement inonMounted.
Every statement on this page is the result of running the library’s SSR stand with hydration, not of reading the sources.