Skip to content

SSR & Hydration ​

The server does not know the size of the browser, so a page that renders from viewport state is rendered for a guess and then meets the real value on the client. This page shows the three levels of dealing with that, from the simplest to the one that removes the mismatch for returning visitors.

The three levels ​

LevelWhat the server rendersHydration warning when the browser differs
ssrStateA fixed layout you chooseYes, unless hydration is deferred
Deferred hydrationThe same fixed layoutNo — the real value arrives after mounting
Request hintsA layout chosen from the requestNo, and usually no layout jump either

ssrState: a fixed guess ​

Without help the server renders every key as false, which usually matches no layout at all. The ssrState option sets the values a key takes instead of false whenever matchMedia (viewport) or ResizeObserver (container) is unavailable:

ts
const state = createResponsiveState(config, {
  ssrState: { desktop: true },
})

state.getState() // on the server: { mobile: false, tablet: false, desktop: true }
state.getSsrState() // the same values, on the server and in the browser
  • Keys that are not in the config are ignored, and keys that are not listed stay false.
  • In a browser the option is ignored and the real value wins. getSsrState() still returns the seeded values, which is what the adapters use to hydrate safely.
  • Pick the layout most of your visitors get, usually desktop.

Deferred hydration ​

When the browser's real size differs from ssrState, the first client render differs from the server HTML and Vue reports a hydration mismatch. Deferred hydration removes it: while the page hydrates, the client uses the same values the server used, and switches to the real ones right after mounting.

Vue ​

ts
import { defineResponsive } from 'responsive-media/vue'

export const { useResponsive, plugin } = defineResponsive(config, {
  ssrState: { desktop: true },
  hydration: 'deferred',
})
  • hydration: 'immediate' (default) — the real state is used from the first render, as before. A mismatch is possible.
  • hydration: 'deferred' — the plugin freezes the state at the server snapshot while the app mounts over server-rendered markup, and applies the real values on the next tick. The page renders twice, and the second render is the real one.
  • commit — when the real values arrive. 'auto' (default) is the next tick after mounting. 'manual' keeps the server's values until you call commitHydration(), which is the right choice for a page with async components or lazy hydration: they hydrate later than the first tick, and with 'auto' they would meet the real state and mismatch. commitTimeout (default 10000 ms) commits on its own if commitHydration() is never called; 0 turns the timeout off.
  • Client-only apps are not deferred. The plugin defers only when the container already has server-rendered content, or when you pass hydrating: true to app.use(plugin, { hydrating }). A plain createApp into an empty element uses the real state at once.
  • The plugin also accepts these as install options: app.use(plugin, { ssrState, hydration: 'deferred', commit: 'manual' }). defineResponsive() returns commitHydration next to plugin.

React ​

Nothing to configure. useSyncExternalStore asks the hooks for a server snapshot while React hydrates, and the hooks return getSsrState(). The first client render matches the server HTML, and React re-renders with the real value afterwards without a recoverable error.

Nuxt ​

The module defaults to hydration: 'deferred', passes nuxtApp.isHydrating to the plugin, and commits the real values when Nuxt reports that the page has finished hydrating (app:suspense:resolve) — async components included — so a Nuxt app needs no extra code. See Nuxt Integration.

A fixed guess is wrong for every visitor who is not on the guessed device, and deferred hydration then shows the wrong layout for a frame. Request hints give the server a better guess from the request itself:

  • cookie — the client writes its own viewport size to a cookie (<width>x<height>, for example 390x844), and the server reads it on the next request. For a returning visitor the guess is exact.
  • user-agent — the server classifies the device as mobile, tablet or desktop and renders for a representative size of that class.

Hints are tried in the order you give them; the first one that yields a value wins, and ssrState is the fallback.

ts
import { resolveSsrState } from 'responsive-media'

const ssrState = resolveSsrState(
  config,
  { cookie: request.cookies['responsive-viewport'], userAgent: request.headers['user-agent'] },
  { hints: ['cookie', 'user-agent'], fallback: { desktop: true } },
)

resolveSsrState(config, input, options?)

  • input — { cookie?: string | null; userAgent?: string | null }.
  • options.hints — ('cookie' | 'user-agent')[] · default: ['cookie', 'user-agent'].
  • options.devices — Partial<Record<'mobile' | 'tablet' | 'desktop', { width: number; height: number }>>. The sizes a user agent is rendered for. The defaults:
DeviceWidthHeight
mobile390844
tablet8201180
desktop1440900
  • options.fallback — Record<string, boolean>. The values used when no hint applies.

The result is computed by evaluating the config against the size, so it understands width, height, orientation and aspect-ratio conditions — the same ones container state understands.

The pieces ​

  • detectDevice(userAgent) — 'mobile' | 'tablet' | 'desktop' | null (null for an empty user agent). An iPad that asks for the desktop site sends a Mac user agent and is seen as desktop.
  • serializeViewportCookie(size) and parseViewportCookie(value) — the <width>x<height> format; the parser returns null for anything else.
  • statesFromSize(config, size) — the boolean state of a config for a given { width, height }.
  • VIEWPORT_COOKIE — the default cookie name, 'responsive-viewport'.

In Nuxt ​

Set ssrHints in the module options and the module does all of the above for you — see Nuxt Integration.

Caching ​

With hints on, the HTML depends on the Cookie and User-Agent headers. A CDN or reverse proxy that caches pages must vary on them (Vary: Cookie, User-Agent) or must not cache these pages, otherwise one visitor receives another's layout. Prerendered pages have no request, so they always use the ssrState fallback.

What the server cannot know ​

useMediaQuery() and container state have no server value: useMediaQuery() is false and the container state is empty (falsy for every key) until the browser takes over. A part of the page that depends on them and must never mismatch belongs in <ClientOnly>, or its layout belongs to CSS.