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.
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.teleportsinserted 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
| API | On 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, 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('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:
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:
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 themeimport { 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
- 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.
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.