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

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`. 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; 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 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

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