# 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.

- Page: https://granularity.tech/docs/ssr
- Last reviewed: 2026-10-20

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.**

:::callout{type="info"}
**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:

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

```ts title="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:

```ts title="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
```

```ts title="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

:::steps
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.
