# SSR

> Пакет безопасен для серверного рендера целиком. Оговорки касаются не безопасности, а того, что именно приходит с сервера.

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

Nuxt, `vite-ssr` или собственный `@vue/server-renderer` — короткий ответ один:
**весь пакет рендерится на сервере и гидрируется без расхождений**. Дальше —
оговорки о том, что именно приезжает с сервера и где нужен плагин.

## Телепорт включается после гидрации

Панели, всплывашки и оверлеи живут в `body`, но попадают туда не сразу. Контракт
один на все компоненты: телепорт выключен на сервере **и на первом клиентском
рендере**, а включается в `onMounted`. Отключённый телепорт означает «рендерить
на месте», поэтому серверный HTML и первый клиентский рендер совпадают.

Что из этого следует для приложения:

- **панели приходят с сервера** — внутри разметки своего компонента и скрытыми,
  поэтому вспышки раскрытого списка нет;
- **вставлять `ssrContext.teleports` в разметку пакету не нужно**: у его
  телепортов там одни якоря-комментарии, и гидрация проходит чисто и без них.
  Вставляйте их, если у вас есть свои телепорты, включённые на сервере;
- **обёртки client-only не нужны ни одному компоненту пакета.**

:::callout{type="info"}
**Исключение — содержимое модальных слоёв.** `GrModal` и всё, что на нём
построено (`GrDialog`, `GrConfirmDialog`, `GrPromptDialog`, `GrCommandPalette`),
а также `GrDrawer`, `GrImageViewer` и `GrSidebar` в режиме `overlay` не отдают
содержимое на сервер вообще, даже будучи открытыми: слой рисуется только после
включения телепорта. Практический вывод: в первый экран и в поисковую выдачу оно
не попадает, а расхождения гидрации внутри него в принципе невозможны.
:::

Пишете свой компонент с телепортом — берите `usePortalTarget()` и его `enabled`,
а не `typeof window !== 'undefined'`. Проверка окружения выглядит достаточной,
но именно она и создаёт расхождение: на сервере и на первом клиентском рендере
она даёт разный результат.

## Композаблы: что требует плагина

| API | На сервере |
|---|---|
| `useTheme()` | Читать можно, писать — только с `granularityThemePlugin`. Течёт именно мутация: модульное состояние общее на все запросы |
| `useToast()` | **Требует** `granularityToastPlugin`; без него бросает с объяснением |
| `useDialogService()` | Сам композабл безопасен; методы, открывающие окно (`confirm`, `alert`, `prompt`, `open`), на сервере бросают: хост монтируется в `document.body` |
| `useAnnouncer()` | Безопасен, объявление — no-op: живой регион это узел документа, а не переносимое состояние |
| `useGrConfig()`, `useGrFormFieldContext()` | Чистые, DOM не трогают |
| `useOverlayLayer()` | Безопасен: на сервере слой в стек не заводится вовсе |
| `vClickOutside`, `vHotkey`, `vLoading` | Работают в хуках монтирования, на сервере не вызываются |

Общее правило у запретов одно: **модульное состояние на сервере — это состояние,
общее для всех запросов**, и выбор одного пользователя уехал бы в ответ другому.
Плагин даёт каждому приложению своё.

## `ClientOnly` — кому он нужен

Компонентам пакета — ни одному, и оборачивать их вредно: пропадёт серверная
разметка, а с ней и смысл SSR. Обёртка нужна **вашему** коду, который читает
среду прямо в шаблоне: ширину окна, `navigator`, `localStorage`, время в
локальной зоне.

Императивные API она не закрывает вовсе — они не рендерятся, прятать надо
**вызов**, а не разметку:

```ts
if (typeof window !== 'undefined')
  await useDialogService().confirm('Delete the item?', { title: 'Delete?' })
```

## Тема без мигания

Сервер не знает, какую тему выбрал пользователь. Решение — инлайновый скрипт в
`<head>` до первого рендера:

```html
<script>
  try {
    var t = localStorage.getItem('gr-theme')
      || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    document.documentElement.dataset.theme = t
  }
  catch {}
</script>
```

Ключ `gr-theme` и атрибут `data-theme` — тот же контракт, что использует
`useTheme()`. Если тему выбирает сервер — из куки или профиля, — ставьте
`granularityThemePlugin`: простому SPA он не нужен, там модульного синглтона
достаточно.

CSS на серверный рендер не влияет: он статичен. Критично только одно — тема
должна примениться до первой отрисовки, иначе пользователь с тёмной темой
увидит вспышку светлой.

## Правила для своего компонента

:::steps
1. DOM — только в `onMounted`/`onBeforeUnmount` и в обработчиках. Ни одного обращения в теле `setup` и на уровне модуля.
2. Нужен `document` или `window` вне хука — гард `typeof window === 'undefined'`, а не `try/catch`.
3. Телепортируете — берите `enabled` из `usePortalTarget()`, не проверку окружения.
4. Браузерные API, которых может не быть и в браузере (`ResizeObserver`, `matchMedia`), проверяйте на существование.
5. DOM-идентификаторы — только `useId()`. Ни счётчик экземпляра (на сервере он растёт между запросами, на клиенте стартует с нуля), ни случайное число: расхождение придёт молча — через `id`, `aria-controls`, `aria-labelledby`.
6. Значение, зависящее от среды, в первом рендере не использовать: `ref` с серверным значением и уточнение в `onMounted`.
:::

Поведение телепортов, модальных слоёв, `useId()`, тостов и темы на этой
странице проверено прогоном SSR-стенда библиотеки с гидрацией; поведение
остальных композаблов и директив — по их серверным гардам в коде. Nuxt и
потоковый рендер стенд не покрывает.
