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

APIOn 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, vLoadingThey 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:

ts
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:

html
<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

  1. The DOM only in onMounted/onBeforeUnmount and in handlers. Not a single access in the body of setup or at module level.
  2. If you need document or window outside a hook, guard with typeof window === 'undefined' rather than with try/catch.
  3. If you teleport, take enabled from usePortalTarget(), not an environment check.
  4. Browser APIs that may be missing even in a browser (ResizeObserver, matchMedia) have to be checked for existence.
  5. 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 — through id, aria-controls, aria-labelledby.
  6. A value that depends on the environment must not be used in the first render: a ref with the server value and a refinement in onMounted.

Every statement on this page is the result of running the library’s SSR stand with hydration, not of reading the sources.

Last reviewed: 2026-09-01