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
| Level | What the server renders | Hydration warning when the browser differs |
|---|---|---|
ssrState | A fixed layout you choose | Yes, unless hydration is deferred |
| Deferred hydration | The same fixed layout | No — the real value arrives after mounting |
| Request hints | A layout chosen from the request | No, 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:
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
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 callcommitHydration(), 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(default10000ms) commits on its own ifcommitHydration()is never called;0turns 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: truetoapp.use(plugin, { hydrating }). A plaincreateAppinto 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()returnscommitHydrationnext toplugin.
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.
Request hints: cookie and user agent
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 example390x844), and the server reads it on the next request. For a returning visitor the guess is exact.user-agent— the server classifies the device asmobile,tabletordesktopand 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.
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:
| Device | Width | Height |
|---|---|---|
mobile | 390 | 844 |
tablet | 820 | 1180 |
desktop | 1440 | 900 |
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(nullfor an empty user agent). An iPad that asks for the desktop site sends a Mac user agent and is seen asdesktop.serializeViewportCookie(size)andparseViewportCookie(value)— the<width>x<height>format; the parser returnsnullfor 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.