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.
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:
| Cookie | Drives |
|---|---|
theme | The dark class on html (light / dark / system) |
design_skin | The data-skin attribute |
oled_dark | The data-oled attribute |
layout_config | The 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
| File | Role |
|---|---|
src/lib/appStorage.ts | SSR_COOKIE_KEYS, persist(), enableCookieMirror() |
src/app/serverPrefs.ts | Reads and validates the cookies, returns ServerPrefs |
src/app/[locale]/layout.tsx | Puts the result on html (class + data-*) |
src/app/[locale]/providers.tsx | Seeds the client providers with the same values |
src/hooks/useTheme.ts | ServerThemeProvider, 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">
Cookie mirroring is opt-in
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.
- Add the key to
SSR_COOKIE_KEYSinsrc/lib/appStorage.ts. - Write it through
persist(). - Read and validate it in
src/app/serverPrefs.ts. - 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 —
themeagainst the three modes,design_skinagainstSKINS,oled_darkagainst the exact stringtrue,layout_configthroughJSON.parsein atry. 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_configis 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.
Related
Was this page helpful?
