Setting the Initial Theme on the Server from Cookies

Part of SSR Hydration Fallback Chains. This page covers the exact mechanics of reading a theme cookie at request time, stamping data-theme on the <html> element during server-side rendering, and keeping that decision consistent all the way through client hydration — so the browser never sees a flash of the wrong theme and React never emits a hydration warning.

Cookie → SSR → Hydrate sequence A left-to-right sequence showing a browser request with a theme cookie arriving at the server, the server stamping data-theme on the HTML element, the painted HTML being sent to the browser, and React hydrating without a mismatch warning. Browser GET /page Cookie: theme=dark Server Handler 1. Parse Cookie header 2. Resolve theme value 3. Stamp data-theme on <html> element 4. Set Vary: Cookie SSR HTML <html data-theme="dark" > Painted immediately Hydrate Matched no warning request response React
Full request cycle: the theme cookie travels from the browser to the server, the server stamps data-theme on <html> before sending any bytes, and React hydrates against a DOM that already matches its expected output.

Prerequisites

Before wiring cookie-based SSR theming, confirm the following are in place:

  • A theme toggle on the client that persists the user’s choice. It must write both a JavaScript-accessible value and an HTTP cookie — not only localStorage.
  • A server runtime that can read Cookie request headers: Node.js (http, Express, Fastify), a Deno Deploy function, or an edge runtime such as Cloudflare Workers.
  • CSS that uses [data-theme="dark"] (or [data-theme="light"]) selectors on :root / html, not @media (prefers-color-scheme) alone — the attribute selector is what the server output drives.
  • A defined set of safe theme values (light, dark, and optionally system) with a validated allowlist so the cookie value is never trusted raw.
  • If using a CDN in front of the origin, the ability to configure Vary headers or cache keys per cookie value (covered in Step 6).

Step-by-Step Implementation

The client toggle must write an HTTP cookie in addition to whatever in-memory state it manages. Set SameSite=Lax and omit HttpOnly so JavaScript can read it too (you need client-side reads to keep the cookie in sync without a round-trip).

// theme-toggle.js — runs in the browser
const ALLOWED_THEMES = new Set(['light', 'dark']);
const COOKIE_NAME = 'theme';

export function applyTheme(value) {
  if (!ALLOWED_THEMES.has(value)) return;

  // Stamp the DOM immediately (no flash)
  document.documentElement.setAttribute('data-theme', value);

  // Write the HTTP cookie — server will read this on next request
  document.cookie = [
    `${COOKIE_NAME}=${value}`,
    'Path=/',
    'Max-Age=31536000', // 1 year
    'SameSite=Lax',
  ].join('; ');
}

Why this works: cookies set with Path=/ are sent by the browser on every subsequent navigation to that origin, including full-page loads and server-side renders triggered by link clicks. localStorage alone never reaches the server.

A minimal Node/edge handler that reads the Cookie header and resolves a safe theme value:

// theme-utils.js — Node.js / edge runtime, no framework dependency
const ALLOWED_THEMES = new Set(['light', 'dark']);
const DEFAULT_THEME = 'light';

/**
 * Parse the raw Cookie header string and return a validated theme name.
 * Falls back to DEFAULT_THEME when the cookie is absent or invalid.
 */
export function resolveThemeFromCookie(cookieHeader = '') {
  const match = cookieHeader.match(/(?:^|;\s*)theme=([^;]+)/);
  const raw = match ? decodeURIComponent(match[1]).trim() : '';
  return ALLOWED_THEMES.has(raw) ? raw : DEFAULT_THEME;
}

Why this works: the allowlist ensures a malformed or injected cookie value can never produce unexpected HTML. decodeURIComponent handles edge cases where the browser percent-encoded the value.

Step 3 — Stamp data-theme on <html> during render

Inject the resolved theme into the outermost HTML element before any bytes are flushed to the client. The exact API differs by framework, but the principle is identical everywhere.

Framework-neutral Express / Node handler:

// server.js
import { createServer } from 'node:http';
import { resolveThemeFromCookie } from './theme-utils.js';
import { renderToString } from 'your-framework'; // React, Vue, Solid, etc.

createServer((req, res) => {
  const theme = resolveThemeFromCookie(req.headers.cookie);

  const appHtml = renderToString(/* <App /> */);

  const html = `<!DOCTYPE html>
<html lang="en" data-theme="${theme}">
  <head>
    <meta charset="utf-8"/>
    <link rel="stylesheet" href="/tokens.css"/>
  </head>
  <body>
    <div id="root">${appHtml}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`;

  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Vary': 'Cookie',
  });
  res.end(html);
}).listen(3000);

Why this works: data-theme is present in the HTML string before a single byte reaches the browser. The browser paints against already-correct CSS custom property values, producing no flash and no DOM divergence for the hydration pass.

Next.js App Router note: In the App Router, use the cookies() helper from next/headers inside a Server Component or a root layout:

// app/layout.jsx  (Server Component — no 'use client' directive)
import { cookies } from 'next/headers';

const ALLOWED = new Set(['light', 'dark']);

export default function RootLayout({ children }) {
  const cookieStore = cookies();
  const raw = cookieStore.get('theme')?.value ?? '';
  const theme = ALLOWED.has(raw) ? raw : 'light';

  return (
    <html lang="en" data-theme={theme}>
      <body>{children}</body>
    </html>
  );
}

Why this works: cookies() in a Server Component reads the incoming request headers server-side without any client bundle code. The data-theme attribute is serialized directly into the RSC payload and into the initial HTML.

Step 4 — Write the CSS token layer driven by the attribute

Your CSS must use the [data-theme] attribute as the theming hook, not media queries alone. Keep token declarations in a dedicated layer so they can be overridden cleanly.

/* tokens.css */
@layer tokens {
  :root,
  [data-theme="light"] {
    --ds-color-bg-surface: #ffffff;
    --ds-color-bg-subtle: #f1f5f9;
    --ds-color-text-primary: #0f172a;
    --ds-color-text-secondary: #475569;
    --ds-color-border-default: #e2e8f0;
    --ds-color-action-primary: #2563eb;
    color-scheme: light;
  }

  [data-theme="dark"] {
    --ds-color-bg-surface: #0f172a;
    --ds-color-bg-subtle: #1e293b;
    --ds-color-text-primary: #f8fafc;
    --ds-color-text-secondary: #94a3b8;
    --ds-color-border-default: #334155;
    --ds-color-action-primary: #60a5fa;
    color-scheme: dark;
  }
}

Why this works: color-scheme on the element tells the browser which system UI chrome (scrollbars, form controls, input backgrounds) to render, independently of your custom property values. Declaring it inside the same selector block keeps the pairing explicit and prevents a mismatched scrollbar color on dark mode.

Step 5 — Keep the client in sync without a second round-trip

After React (or your framework) hydrates, wire the toggle to call applyTheme() from Step 1. This function both updates the DOM attribute and rewrites the cookie, so the next server-rendered page load gets the correct value automatically — no additional fetch needed.

// ThemeToggle.jsx  (client component)
'use client';
import { applyTheme } from '../theme-toggle.js';

export function ThemeToggle() {
  function handleClick() {
    const current = document.documentElement.getAttribute('data-theme') ?? 'light';
    applyTheme(current === 'dark' ? 'light' : 'dark');
  }

  return (
    <button type="button" onClick={handleClick} aria-label="Toggle theme">
      Toggle theme
    </button>
  );
}

Why this works: the toggle reads the current data-theme attribute directly from the DOM rather than from React state. This means it is correct even if the server stamped a value that differs from any client-side default — the attribute is the single source of truth post-hydration.

Any CDN or reverse proxy between your origin and the browser must cache separate HTML responses per theme value. Without this, a CDN might serve a cached data-theme="light" response to a user whose cookie says dark.

// Express / generic Node middleware
res.setHeader('Vary', 'Cookie');

// Alternatively, use a more specific cache-control directive
// that only varies on the theme cookie value (CDN-dependent syntax):
// Surrogate-Key: theme-${resolvedTheme}
// Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300

For CDNs that support custom cache keys (Cloudflare, Fastly, AWS CloudFront), configure the cache key to include only the theme cookie name, not the entire Cookie header. A full-header vary would destroy cache efficiency:

# Cloudflare Cache Rules (via dashboard or Terraform)
cache_key:
  custom_key:
    cookie:
      include: ["theme"]

Why this works: scoping the cache key to just the theme cookie value means you maintain two cache buckets (one per valid theme) instead of one uncacheable bucket per unique cookie string. This pattern is covered in depth in the per-tenant runtime theming section, where the same CDN partitioning logic applies to tenant IDs.

Because the cookie value is written directly into an HTML attribute, it must be treated as untrusted input regardless of the allowlist. The allowlist in Step 2 is the primary defense, but add an explicit HTML-attribute escape as defense-in-depth:

// theme-utils.js (additions)
const ATTR_SAFE_PATTERN = /^[a-z][a-z0-9-]*$/;

export function safeThemeAttr(theme) {
  // Only output the value if it matches a strict identifier pattern.
  return ATTR_SAFE_PATTERN.test(theme) ? theme : 'light';
}

Use safeThemeAttr(resolvedTheme) when constructing the HTML string. Theme names like light, dark, and high-contrast all pass; anything containing ", >, or script content fails and falls back to the default.

Verification

1. View-source check: Open the page URL in the browser and select View Source (not the DevTools Elements panel, which shows the live DOM). The raw HTML response must contain <html ... data-theme="dark"> (or light) before any <script> tags. If the attribute is absent at view-source time, the cookie is not being read server-side.

2. React hydration warning check: Open the browser console and reload. A correctly wired implementation produces zero Warning: Prop ... did not match or Hydration failed messages. If you see these warnings, the server and client resolved different theme values — compare what resolveThemeFromCookie returns against what the client would compute from the same cookie.

3. Network tab cookie confirmation: In DevTools → Network, select the HTML document request and expand the Request Headers section. Confirm Cookie: theme=dark (or light) appears. If it does not, the document.cookie write in the toggle is missing the correct Path=/ attribute.

4. Playwright smoke test:

// tests/theme-ssr.spec.js
import { test, expect } from '@playwright/test';

test('server stamps data-theme from cookie', async ({ context, page }) => {
  // Manually set the cookie before navigation
  await context.addCookies([{
    name: 'theme',
    value: 'dark',
    domain: 'localhost',
    path: '/',
  }]);

  await page.goto('http://localhost:3000/');

  // Attribute must be present in the initial HTML, not added by JS
  const theme = await page.evaluate(() =>
    document.documentElement.getAttribute('data-theme')
  );
  expect(theme).toBe('dark');

  // No hydration warnings — checked via console message count
  const warnings = [];
  page.on('console', msg => {
    if (msg.type() === 'warning' && msg.text().includes('Hydration')) {
      warnings.push(msg.text());
    }
  });
  await page.reload();
  expect(warnings).toHaveLength(0);
});

Troubleshooting

Symptom Likely Cause Fix
React hydration mismatch warning on every load Server resolves a different theme than the client’s initial render. Usually the server defaults to light while the client reads a stale localStorage value during hydration. Remove any localStorage read from the component tree’s initial render path. The only source of truth at hydration time should be the data-theme attribute already on <html>.
CDN serving the wrong theme to some users Vary: Cookie is set but the CDN is ignoring it or caching the full cookie string as one key. Narrow the CDN cache key to include only the theme cookie name. Check whether the CDN requires the origin to set a Surrogate-Control or custom cache key header rather than relying on Vary.
Cookie not sent to the server (Cookie header absent) The cookie was written with an incorrect Path, a different Domain, or SameSite=Strict which blocks cross-origin navigations. Rewrite the cookie with Path=/; SameSite=Lax. Verify by checking Request Headers in DevTools Network for the HTML document request.
data-theme appears in view-source but is always "light" resolveThemeFromCookie is receiving an empty string because the cookie header is not being forwarded. Common in edge runtimes or behind a reverse proxy that strips cookies. Confirm the proxy passes Cookie headers to the origin. In Vercel Edge Middleware, use request.cookies.get('theme') rather than parsing the raw header.
Theme flashes briefly on navigation between pages The server is slow to respond and the browser paints a partial HTML chunk before data-theme is in it. Move the data-theme attribute to the very first bytes of the response, or use renderToPipeableStream / renderToReadableStream with the shell containing <html data-theme="..."> flushed before Suspense content.

Migration Note

If your app currently reads localStorage synchronously in a blocking inline script — a common pattern from client-only theming — migrate in two phases rather than cutting over at once.

Phase 1 (parallel write): Keep the inline script reading localStorage, but add the cookie write to the toggle function. The server still renders without a theme attribute. This phase introduces zero breakage and gets the cookie established in real users’ browsers.

Phase 2 (server takeover): Add the server-side resolveThemeFromCookie logic from Step 2 and stamp the attribute in the HTML template. Remove the blocking inline script from <head>. After deploying, monitor for hydration warnings via your error-tracking service. Because the cookie and localStorage values should agree at this point — the toggle has been writing both during Phase 1 — the rate of mismatches should be near zero.

For users who have never toggled and have no cookie, the server falls back to light (or your configured default). The handling SSR hydration mismatches in dark mode page covers how to layer @media (prefers-color-scheme) as a CSS-only fallback for these users until they make an explicit choice.