SSR

  • Integrations
  • 7 min
  • 4 snippets

The package is safe for server rendering as a whole. The caveats are not about safety but about what exactly arrives from the server.

Reviewed 2026-10-20

On this page

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;
  • the package does not need ssrContext.teleports inserted into the markup: its teleports leave only anchor comments there, and hydration is clean without them. Insert them if you have teleports of your own that are enabled on the server;
  • client-only wrappers are needed by none of the package’s components.

The exception is the contents of modal layers. GrModal and everything built on it (GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette), as well as GrDrawer, GrImageViewer and GrSidebar in overlay mode, do not hand their content to the server at all, even while open: the layer is drawn only once the teleport is enabled. 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: mode and isDark return the request’s mode or the default one, while setMode/toggleMode throw without the plugin. What leaks is the mutation: module state is shared across every request
initThemeEarly()Does nothing: its job is to set the pair before mounting, and it is called from the browser entry
useToast()Requires granularityToastPlugin; without it, it throws with an explanation
useDialogService()The composable itself is safe; the methods that open a window (confirm, alert, prompt, open) throw on the server: the host is mounted 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('Delete the item?', { title: 'Delete?' })

A mode without a flash

The mode lives as a pair, data-theme="slate-dark", on <html>, and it has to be set before the first paint. Pairs are not typed by hand: the build knows them and hands them over as the virtual:granum/themes manifest — the same one the CSS is built from.

Without a server — a script in <head>. granum’s plugin inserts it first in the <head> of the application’s HTML entry:

vite.config.ts
granum(granumConfig, { themeScript: { storageKey: 'gr-theme' } })

The script leaves a pair that came from the server alone; otherwise it takes the mode from a cookie (if its name is given), from localStorage, by the system scheme and, finally, the default mode. The gr-theme key is required: granum’s own default key is granum-theme, while useTheme() stores the choice under gr-theme, and if they diverge the choice does not survive a reload. Without the plugin the same string comes from createThemeScript({ manifest, storageKey: 'gr-theme' }) in @feugene/granum/runtime; in Astro the script is set by the @feugene/astro-granularity integration.

With a server — the pair in the HTML. If the choice is kept in a cookie, the server reads it from the request and sends <html data-theme="slate-dark"> right away, and the application renders in the same theme and mode:

entry-server.ts
import { GR_THEME_STORAGE_KEY } from '@feugene/granularity'
import { themeFromCookie, themeRootAttributes, themeSheetLink } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'

const selection = themeFromCookie(manifest, request.headers.cookie, GR_THEME_STORAGE_KEY)
const theme = selection && { theme: selection.theme.id, mode: selection.mode.name }
const htmlAttrs = theme ? themeRootAttributes(manifest, theme) : {} // { 'data-theme': 'slate-dark' }
// an additional theme's sheet goes into <head>: the shared CSS lacks it
const sheet = theme && themeSheetLink(manifest, theme.theme) // undefined for the default theme
app.ts
import { GR_THEME_STORAGE_KEY, granularityThemePlugin } from '@feugene/granularity'
import { createCookieStorage } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'

app.use(granularityThemePlugin, {
  manifest,
  storage: createCookieStorage({ name: GR_THEME_STORAGE_KEY }),
  initial: theme, // not passed on the client: the controller recognises the pair from the server
})

An unknown choice and a missing cookie both give undefined: the server cannot decide for the user by the system scheme, so the script sets the pair then. The theme goes into initial together with the mode: components take their default props by it, and without it the server HTML would diverge from hydration. Do not write the default mode’s pair “just in case” — the script keeps it as the server’s, and the stored choice never applies. A server that imports @feugene/granum/runtime needs granum in dependencies, not in devDependencies.

Both paths meet on the client: useTheme() recognises a pair already on the root and does not overwrite it. color-scheme is declared by the mode’s CSS, so there is no need to set it by hand.

CSS has no effect on server rendering: it is static. Only one thing is critical — the mode has to apply before the first paint, otherwise a user with the dark mode 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.

The behaviour of teleports, modal layers, useId(), toasts and the mode on this page is verified by running the library’s SSR stand with hydration; the behaviour of the other composables and directives comes from their server guards in the code. The stand does not cover Nuxt or streaming rendering.