Loading Tenant Themes at Runtime with CSS Variables
Part of Per-Tenant Runtime Theming. This page walks through resolving a tenant’s token map on the server and injecting it as a :root custom-property block before first paint — eliminating flash of unstyled content — while providing a client-side fallback fetch for SPA route changes.
Prerequisites
- A token store (database, KV store, or static JSON files) keyed by tenant identifier, with at minimum a flat map of
--brand-*custom property names to validated values. - A server runtime that can intercept requests before the HTML response is flushed — Node.js with Express, a Cloudflare Worker, a Next.js middleware, or an equivalent edge handler.
- Tenants disambiguated by subdomain (
acme.app.example.com), a session claim, or a request header — pick one source of truth and stick to it. - Token values that are already resolved (alias chains collapsed) so the inline block contains only concrete values — no unresolved references that would silently fall back to
initial. - Node.js 18+ (for
crypto.subtleused in the value sanitizer below) or a comparable runtime. The CSS output itself is environment-agnostic.
Step-by-Step Implementation
Step 1 — Read the tenant identifier from the request
Determine which tenant owns the request before you touch the token store. Subdomain is the most reliable signal because it survives cookie clearing and anonymous sessions.
// middleware/resolve-tenant.js
export function resolveTenant(req) {
const host = req.headers.host || req.headers[":authority"] || "";
// e.g. "acme.app.example.com" -> "acme"
const subdomain = host.split(".")[0];
const knownTenants = ["acme", "globex", "initech"];
if (knownTenants.includes(subdomain)) {
return subdomain;
}
// Fall back to a session claim for non-subdomain deployments
return req.session?.tenantId ?? "default";
}
Why this works. Subdomain extraction is a single string split — zero I/O, evaluated before any async work. The explicit allowlist prevents a malformed Host header from probing your token store for arbitrary keys.
Step 2 — Look up the tenant’s token map and cache it
Hitting a database on every request to retrieve the same token object is wasteful and adds latency to your TTFB. Cache the resolved map in memory (for single-instance deployments) or at the edge (for distributed runtimes).
// lib/token-store.js
import { readFile } from "node:fs/promises";
import path from "node:path";
const cache = new Map(); // module-level, lives for the process lifetime
export async function getTokenMap(tenantId) {
if (cache.has(tenantId)) {
return cache.get(tenantId);
}
// Token files live at tokens/<tenantId>.json — built from your Style Dictionary pipeline
const filePath = path.resolve("tokens", `${tenantId}.json`);
const raw = await readFile(filePath, "utf8");
const map = JSON.parse(raw); // { "--brand-color-primary": "#0057ff", ... }
cache.set(tenantId, map);
return map;
}
Why this works. A module-level Map persists across requests in long-lived Node processes and Cloudflare Worker isolates. For multi-instance deployments, replace the Map with a KV read that has a short TTL — the shape of the API stays identical to the rest of your middleware chain.
Step 3 — Sanitize every token value before injection
Never interpolate raw token values from any external source directly into a <style> block. A malicious or corrupted token value could break out of a CSS string and inject script or redefine custom properties for other tenants sharing a CDN cache.
// lib/sanitize-token.js
// Allowlist of CSS value patterns that are safe to inject.
// Covers hex colors, rgb/hsl functions, named colors, numbers with units, and bare numbers.
const SAFE_VALUE_RE =
/^(#[0-9a-fA-F]{3,8}|rgb\([^)]{1,60}\)|hsl\([^)]{1,60}\)|[a-zA-Z][a-zA-Z0-9-]{0,40}|[\d.]+(%|px|rem|em|vh|vw|deg|ms|s)?)$/;
export function sanitizeTokenValue(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!SAFE_VALUE_RE.test(trimmed)) {
console.warn(`[theme] Rejected unsafe token value: "${trimmed}"`);
return null;
}
return trimmed;
}
export function sanitizeTokenMap(map) {
const safe = {};
for (const [prop, value] of Object.entries(map)) {
if (!prop.startsWith("--brand-")) continue; // only inject brand namespace
const clean = sanitizeTokenValue(value);
if (clean !== null) {
safe[prop] = clean;
}
}
return safe;
}
Why this works. The regex allowlist rejects anything that contains semicolons, braces, or HTML-special characters, so an injected string like red}body{display:none never reaches the browser. Restricting injection to the --brand-* namespace ensures base tokens from your design system can never be overwritten by a tenant payload.
Step 4 — Render the inline <style> block and inject it into <head>
With a sanitized token map in hand, serialize it to a :root {} rule and write it into the document <head> before any stylesheet link or <body> tag.
// lib/render-theme-style.js
export function renderThemeStyle(safeTokenMap) {
const props = Object.entries(safeTokenMap)
.map(([prop, value]) => ` ${prop}: ${value};`)
.join("\n");
return `<style data-tenant-theme>\n:root {\n${props}\n}\n</style>`;
}
// middleware/inject-theme.js (Express example)
import { resolveTenant } from "./resolve-tenant.js";
import { getTokenMap } from "../lib/token-store.js";
import { sanitizeTokenMap } from "../lib/sanitize-token.js";
import { renderThemeStyle } from "../lib/render-theme-style.js";
export async function injectThemeMiddleware(req, res, next) {
const tenantId = resolveTenant(req);
const rawMap = await getTokenMap(tenantId);
const safeMap = sanitizeTokenMap(rawMap);
res.locals.themeStyle = renderThemeStyle(safeMap);
next();
}
<!-- views/layout.html (any template engine) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Tenant theme vars injected here, before any external stylesheet -->
{{ themeStyle }}
<link rel="stylesheet" href="/static/design-system.css" />
</head>
<body>{{ content }}</body>
</html>
Why this works. An inline <style> in <head> is render-blocking by design — the browser parses it as part of the critical rendering path, making the custom properties available to every subsequent CSS rule before layout. Placing it before external stylesheets ensures the cascade resolves var(--brand-color-primary) against the tenant values, not an empty inheritance chain.
Step 5 — Expose a JSON theme endpoint for SPA route changes
Single-page applications that navigate between tenants after the initial load cannot rely on a new server render. Add a lightweight endpoint that returns only the safe token map as JSON so the client can patch :root in-place.
// routes/theme-api.js (Express)
import { resolveTenant } from "../middleware/resolve-tenant.js";
import { getTokenMap } from "../lib/token-store.js";
import { sanitizeTokenMap } from "../lib/sanitize-token.js";
export async function themeApiHandler(req, res) {
const tenantId = resolveTenant(req);
const rawMap = await getTokenMap(tenantId);
const safeMap = sanitizeTokenMap(rawMap);
res.setHeader("Cache-Control", "private, max-age=300");
res.json(safeMap);
}
// client/apply-tenant-theme.js
export async function applyTenantTheme(tenantId) {
const res = await fetch(`/api/theme?tenant=${encodeURIComponent(tenantId)}`);
if (!res.ok) return; // degrade gracefully — existing vars stay in effect
const tokenMap = await res.json();
const root = document.documentElement;
for (const [prop, value] of Object.entries(tokenMap)) {
root.style.setProperty(prop, value);
}
}
Why this works. Setting style.setProperty on :root via JavaScript creates inline declarations with the highest specificity in the normal flow. They override any --brand-* values in the <style> block written during SSR, so the SPA can re-theme without a page reload. The Cache-Control: private, max-age=300 header prevents a shared CDN node from returning tenant A’s tokens to tenant B while still cutting repeated fetches on the same authenticated session.
Step 6 — Wire the Express app together
// app.js
import express from "express";
import { injectThemeMiddleware } from "./middleware/inject-theme.js";
import { themeApiHandler } from "./routes/theme-api.js";
const app = express();
app.get("/api/theme", themeApiHandler);
// Apply theme injection to all HTML page routes
app.use(injectThemeMiddleware);
app.get("*", (req, res) => {
const themeStyle = res.locals.themeStyle;
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
${themeStyle}
<link rel="stylesheet" href="/static/design-system.css">
</head>
<body><div id="app"></div><script type="module" src="/static/main.js"></script></body>
</html>`);
});
app.listen(3000);
Why this works. Registering the /api/theme route before the catch-all ensures the theme endpoint is never intercepted by the HTML middleware and avoids a recursive theme injection into a JSON response.
Step 7 — Add CDN and reverse-proxy cache segmentation
If a CDN or nginx sits in front of your server, it must segment its cache on tenant identity or it will serve one tenant’s HTML — complete with the inline <style> block — to a different tenant.
# nginx.conf snippet
proxy_cache_key "$host$request_uri";
proxy_cache_valid 200 5m;
# Cloudflare Cache Rule (via Terraform / Wrangler config)
cache_key:
custom_key:
host:
resolved: true # cache varies on the resolved hostname, not just the path
Why this works. Including the host in the cache key means acme.app.example.com/ and globex.app.example.com/ are stored as distinct objects. Without this, the first tenant to load a page poisons the cache for all subsequent tenants on that path — a common production incident with multi-tenant SSR. The same principle applies to the /api/theme endpoint: set Vary: Host or use per-tenant cache keys consistently.
Verification
View-source check. After the server is running, load any tenant URL and view the page source. You should see a <style data-tenant-theme> block as the first child of <head> containing only --brand-* declarations:
<style data-tenant-theme>
:root {
--brand-color-primary: #0057ff;
--brand-color-surface: #f0f4ff;
--brand-font-family-heading: "Inter", sans-serif;
}
</style>
No-flash check. In Chrome DevTools, open the Network tab, set CPU throttling to 4x slowdown and network to Slow 3G, then hard-reload. The page should paint with brand colors on the first visible frame — no brief flash of unstyled or wrong-color content. Compare --brand-color-primary in the computed styles panel immediately after the DOMContentLoaded event; it should already reflect the tenant value.
SPA path check. Trigger a tenant switch via your SPA’s routing layer and confirm via the DevTools Elements panel that document.documentElement.style gains inline --brand-* declarations within one animation frame after applyTenantTheme resolves.
CI smoke test. Add a Node test that calls injectThemeMiddleware with a mock request for each known tenant and asserts that res.locals.themeStyle contains the expected --brand-color-primary value and does not contain any characters outside the sanitized allowlist:
// test/inject-theme.test.js
import assert from "node:assert/strict";
import { test } from "node:test";
import { injectThemeMiddleware } from "../middleware/inject-theme.js";
test("acme tenant gets correct primary color", async () => {
const req = { headers: { host: "acme.app.example.com" }, session: {} };
const res = { locals: {} };
await injectThemeMiddleware(req, res, () => {});
assert.match(res.locals.themeStyle, /--brand-color-primary:\s*#[0-9a-f]{6}/i);
});
test("theme block contains no script injection vectors", async () => {
const req = { headers: { host: "acme.app.example.com" }, session: {} };
const res = { locals: {} };
await injectThemeMiddleware(req, res, () => {});
assert.doesNotMatch(res.locals.themeStyle, /<\/style>/); // no early close
assert.doesNotMatch(res.locals.themeStyle, /javascript:/i);
});
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| All users see the same tenant’s colors | CDN or reverse-proxy cache key does not vary on host | Add the tenant subdomain to the cache key; use Vary: Host on the theme endpoint |
| Stale tokens after a brand update | Module-level token cache holds old values across deploys | Tie cache invalidation to your deploy hook, or add an ETag-based check on the token file |
| Flash of default tokens on SPA navigation | applyTenantTheme called after React/Vue render cycle, not before |
Call applyTenantTheme in your router’s beforeEach or beforeRouteEnter guard, before the component tree mounts |
| Token values silently stripped | Sanitizer regex rejects a valid value (e.g., rgb(0 90 255) with space syntax) |
Extend SAFE_VALUE_RE to cover the CSS Color 4 space-separated rgb() syntax; add a unit test for the new pattern |
| Cross-tenant token bleed in dev | Multiple tabs sharing localhost with no subdomain differentiation | Use req.session.tenantId as the fallback and set different session values per tab during development |
Migration Note
If you are migrating from hardcoded per-tenant CSS files (acme.css, globex.css) loaded via a conditional <link> tag, move through three phases:
- Dual-load phase. Keep the existing
<link>tag but also inject the inline<style data-tenant-theme>block. The inline block wins the cascade because it appears later in source order. Verify no visual regressions across tenants. - Token extraction phase. Run a one-time script to extract all hardcoded values from each tenant CSS file into JSON token maps. This is also the right moment to adopt the layering approach from brand theme cascade layers so that base tokens resolve cleanly before brand overrides.
- Legacy removal phase. Once CI confirms parity, remove the conditional
<link>tags and the static per-tenant CSS files. The inline injection is now the sole source of tenant identity in CSS.
This phased approach avoids a flag day and makes the diff reviewable per tenant rather than all at once. The server-side injection technique shares a conceptual pattern with setting the initial theme on the server from cookies — both eliminate FOUC by resolving preferences before the first byte of HTML is flushed.
Related
- Per-Tenant Runtime Theming — parent overview covering the full architecture of per-tenant theme delivery
- Layering Brand Themes with Cascade Layers — how to structure the
@layerorder so tenant overrides never fight base tokens - Setting the Initial Theme on the Server from Cookies — cousin SSR technique for user-preference theme injection using the same before-first-paint strategy