PVR Tech Studio
Server rendering

Server Rendering & First Paint

The shell's first byte already carries the visitor's own design — their theme, colour skin, OLED mode and layout configuration — so there is no flash of the default look and no jump after hydration.

5 min read
Updated August 17, 2026

Overview

A client-only dashboard reads its saved preferences after the JavaScript loads, which means the first frame is always the default design. On a dark-theme install that is a white flash; on a custom skin it is a visible colour swap. This edition removes it by deciding the appearance on the server, before any HTML exists.

The problem is that browser storage is not readable by a server. So the small number of preferences the first paint actually depends on are written to a cookie as well as localStorage:

CookieDrives
themeThe dark class on html (light / dark / system)
design_skinThe data-skin attribute
oled_darkThe data-oled attribute
layout_configThe shell's layout flags (sidebar mode, fixed nav, and the rest)

Everything else — favourites, chat state, board data, recent routes — stays storage-only, because it affects content below the fold of the first paint rather than the shell's appearance.

Architecture & files

FileRole
src/lib/appStorage.tsSSR_COOKIE_KEYS, persist(), enableCookieMirror()
src/app/serverPrefs.tsReads and validates the cookies, returns ServerPrefs
src/app/[locale]/layout.tsxPuts the result on html (class + data-*)
src/app/[locale]/providers.tsxSeeds the client providers with the same values
src/hooks/useTheme.tsServerThemeProvider, and the server snapshot

The flow is one round trip: a preference change writes both a storage entry and a cookie, and the next request renders with it already applied.

click "dark"  →  persist('theme','dark')  →  localStorage + cookie
                                                     │
next request ─────────────────────────────────────────┘
   serverPrefs reads + validates  →  <html class="dark" data-skin="graphite">

persist() only writes cookies after enableCookieMirror() has been called, and only this edition calls it. That keeps a client-only build writing zero cookies — it has no server to inform, and a template that ships a cookie-consent flow should not be setting cookies it does not need.

// Correct — mirrors to a cookie when the host asked for it.
persist('theme', 'dark')
 
// Wrong — the server will not see this on the next request.
localStorage.setItem('theme', 'dark')

Use persist() for anything in SSR_COOKIE_KEYS.

Usage

Nothing to wire up: the shell already does this. What matters is knowing the rule when you add a preference that the first paint depends on.

  1. Add the key to SSR_COOKIE_KEYS in src/lib/appStorage.ts.
  2. Write it through persist().
  3. Read and validate it in src/app/serverPrefs.ts.
  4. Apply it in src/app/[locale]/layout.tsx.

If the value only affects content further down the page, skip all of this and leave it in localStorage.

API / Props

readServerPrefs(): Promise<ServerPrefs> — call from a Server Component.

export interface ServerPrefs {
    themeMode: 'light' | 'dark' | 'system'
    /** Resolved light/dark. `system` cannot be resolved server-side, so it renders light. */
    dark: boolean
    skin: Skin
    oled: boolean
    config: LayoutConfig
}

Two details in the implementation are deliberate:

  • Every value is checked against an allowlist — theme against the three modes, design_skin against SKINS, oled_dark against the exact string true, layout_config through JSON.parse in a try. Cookies are user-controlled input and these end up in rendered attributes, so an unrecognised value falls back to the default rather than reaching the DOM.
  • layout_config is merged over the defaults, so a cookie written by an older version that predates a newly added flag can never leave a field undefined.

Configuration & customization

theme: 'system' is the one case a server cannot decide. It depends on the visitor's operating system, which no request header reliably reports. For that mode only, a tiny inline script in head applies the class before first paint. Light and dark are already resolved server-side, so the script is a narrow fallback rather than the mechanism — and its body is a fixed literal with no interpolated value, so the cookie decides only whether it renders.

Resetting clears the cookies too. clearAppStorage() expires every mirrored cookie alongside the storage keys. Without that, a server-rendered reload would restore the very design the user just reset, because the cookie would outlive the cleared storage.

Notes & gotchas

The shell's routes are dynamic, and that is correct. Reading cookies opts a route out of static prerendering — you will see those routes marked dynamic in the build output. For a per-visitor admin surface that is the intended behaviour, not a regression to optimise away. A genuinely public page (marketing, pricing) can still be static because it does not read the cookie jar.

A module-level store cannot hold one visitor's value. This is the subtlest constraint in the whole edition. On a server, module scope is shared between concurrent requests, so a store initialised from "the current theme" would leak one visitor's preference into another's response. That is why useTheme takes its server value from context (ServerThemeProvider) rather than from its own module state.

Stores need a real getServerSnapshot. React uses the third argument to useSyncExternalStore for the server render and for the client's hydration render. Returning the live, storage-derived value defeats the purpose — the two renders disagree and you get a hydration mismatch. useFavorites, usePresence and useRecentRoutes each return a stable module-level default instead.

Prefer useMounted() over a typeof document check. typeof document !== 'undefined' is false on the server but true on the client's first render, which is precisely the mismatch it looks like it is preventing. useMounted() is false for both and flips after mount; every createPortal call site goes through it.

Do not cache the HTML without including Cookie in the cache key. A CDN that caches these responses will serve one visitor's design to everyone else, and a proxy that strips cookies breaks the feature outright — every visitor gets the default and then jumps. See Deploying the Next.js Edition.

Was this page helpful?