Theming
How CSS variable tokens and component template packs combine to give each tenant its own look and feel.
Theming has two independent layers that compose on top of each other:
- CSS variable tokens — colors, spacing, font. Config-only; no code deploy. Covers ~80% of use cases.
- Template packs — actual component code that changes the structure/markup of the UI (different Navbar, Footer, product layout, etc.). Code that ships in the Worker bundle; keep to a small finite set (~6–12).
If you only need to change colours or fonts for a tenant, use layer 1 (theme.tokens/theme.fontKey). Only author a new pack when the *structure* of a component must differ across tenants.
---
Layer 1 — CSS Variable Tokens
How tokens are applied
src/lib/theme.js buildTenantCss(tenant) generates an inline CSS string from the tenant config and injects it in <head> as <style id="tenant-theme"> in src/app/[tenant]/[locale]/layout.js:
const tenantCss = buildTenantCss(tenant);
// → injected as: <style id="tenant-theme">{tenantCss}</style>The generated CSS looks like:
/* if font has a src woff2 */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
:root {
--font-sans: Poppins, system-ui, sans-serif;
--primary: #cc434e;
--primary-light: #d9636d;
--navbar-height: 160px;
/* … any key from tenant.theme.tokens … */
}Only CSS variable names matching --[\w-]+ are accepted (defensive filter in cssSafe()).
Token keys
Set any valid CSS custom property in theme.tokens in src/config/tenants.js. Common tokens used across packs:
Token | Purpose |
|---|---|
| Brand primary colour (buttons, links, accents) |
| Lighter primary tint |
| Darker primary shade |
| Low-opacity primary fill (e.g. tag backgrounds) |
| Primary-tinted border colour |
| Box-shadow primary tint |
| Hover background on neutral surfaces |
| Dark surface (footer, dark sections) |
| Darker border for emphasis |
| Used as |
| Secondary accent colour (harvest pack) |
| Page background (harvest pack) |
| Soft separator colour (harvest pack) |
| Main text colour (harvest pack) |
Tailwind classes like bg-primary, text-primary, border-primary-soft, etc. map to these variables, so pack components automatically reflect each tenant's palette.
Font system
src/lib/theme.js defines a curated FONTS registry. Add a font once; any tenant can reference it by key:
// src/lib/theme.js
export const FONTS = {
poppins: { stack: "Poppins, system-ui, sans-serif" },
inter: { stack: "Inter, system-ui, sans-serif",
src: "/fonts/inter-variable.woff2", weight: "100 900" },
playfair:{ stack: "'Playfair Display', Georgia, serif",
src: "/fonts/playfair-variable.woff2", weight: "400 900" },
};stack— the CSSfont-familyvalue injected into--font-sans.src(optional) — path to a self-hosted.woff2in/public/fonts/; if present, an@font-faceblock is emitted and a<link rel="preload">is injected (fontPreload(tenant)in the layout).- Fonts without a
src(likepoppins) are assumed to be loaded globally or via the system stack — no@font-faceis emitted.
next/font is intentionally not used here: it is build-time static and cannot scale to per-tenant font selection.
---
Layer 2 — Template Packs
Pack model
A template pack is a directory under src/themes/<packId>/ with an index.js that exports a plain object mapping slot names to React components:
// src/themes/aurora/index.js
export default {
id: "aurora",
Navbar,
Footer,
HomeHero,
ProductInfo,
// … other overridden slots …
};Packs export only the slots they override. Everything else falls back to base.
Pack registry
src/themes/registry.js maps each pack id to a dynamic import:
export const registry = {
base: () => import("./base"),
aurora: () => import("./aurora"),
nova: () => import("./nova"),
toys: () => import("./toys"),
silk: () => import("./silk"),
ornaments: () => import("./ornaments"),
harvest: () => import("./harvest"),
};
export const FALLBACK_PACK_ID = "base";The lazy () => import(...) form produces separate browser chunks — only the active tenant's pack code is sent to the client. On the server side (Cloudflare Worker), all packs live in the single Worker bundle, so keep the total count small.
Pack resolution
src/themes/getThemePack.js getThemePack(packId) (React cache()-wrapped):
- Always loads
base. - If
packIdis different from"base", loads the specified pack. - Returns
{ ...base, ...pack }— the pack's exports override matching base slots; missing slots fall back to base.
// src/themes/getThemePack.js
export const getThemePack = cache(async (packId) => {
const base = await load(FALLBACK_PACK_ID);
if (!packId || packId === FALLBACK_PACK_ID) return base;
const pack = await load(packId);
return { ...base, ...pack };
});Available packs
Pack id | Description | Example tenant |
|---|---|---|
| Legacy 3-tier header, coloured footer, banner grid, best-sellers filter |
|
| Wholesale-inspired; full slot override set |
|
| Contemporary layout |
|
| Agricultural/nature store aesthetic |
|
| Playful gift/toy store layout |
|
| Refined minimal layout | — |
| Decorative/gift store layout | — |
---
ThemeSlot Component
src/components/theme/ThemeSlot.jsx is an async server component that resolves the merged pack and renders a named slot:
// src/components/theme/ThemeSlot.jsx
export default async function ThemeSlot({ name, packId, ...props }) {
const pack = await getThemePack(packId);
const Comp = pack?.[name];
if (!Comp) return null;
return <Comp {...props} />;
}Usage in a page:
import ThemeSlot from "@/components/theme/ThemeSlot";
// Inside a server component that has `tenant`:
<ThemeSlot name="ProductInfo" packId={tenant.theme.packId} product={product} showAddToCart={showAddToCart} />Alternatively, resolve the pack once and render multiple slots directly:
import { getThemePack } from "@/themes/getThemePack";
const T = await getThemePack(tenant.theme.packId);
return (
<>
<T.ProductInfo product={product} />
<T.ProductTabs product={product} />
</>
);---
Slot Registry (base pack)
The following slots are defined in src/themes/base/index.js. Every other pack must provide all of them (either with an override component or by re-exporting the same base component) so that npm run check:theme-slots passes.
Slot name | Default component | Typical props |
|---|---|---|
|
|
|
|
|
|
|
|
|
| pack-specific |
|
|
|
|
| pack-specific |
|
|
|
|
|
| same as |
| pack-specific |
|
| pack-specific |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| pack-specific | — |
|
| — |
|
| — |
|
|
|
|
| — |
|
| — |
|
| — |
|
| — |
|
| — |
|
| — |
|
|
|
|
|
|
|
|
|
|
| — |
|
|
|
| pack-specific |
|
| pack-specific | — |
| pack-specific | — |
Prop contracts must be identical across all packs. Pages pass the same props regardless of which pack is active; packs may ignore props they don't use, but must not require additional ones.
---
Theme Slot Validator
scripts/check-theme-slots.mjs (npm run check:theme-slots) enforces two rules:
- No direct component imports in tenant routes. Files under
src/app/[tenant]/must not import from@/components/directly (except an allowed set:ThemeSlot,JsonLd,OrderRedirect,ForcePasswordGuard,WhatsAppButton). All themeable UI must go through a slot. - All base slots must exist in every pack. The
aurorapack (used as the reference for full coverage) must export every slot defined inbase/index.js.
Run it after modifying tenant route files, slot names, or adding a new pack:
npm run check:theme-slotsThe script exits non-zero and lists violations if any check fails.
---
Creating a New Template Pack
Follow these steps only if CSS tokens are not enough and you need structurally different components.
Step 1: Create the pack directory and components
src/themes/<packId>/
index.js
components/
Footer.jsx ← example override
Navbar.jsx
…Every component that overrides a slot must accept the same props as the base version of that slot (see the slot table above).
// src/themes/neon/components/Footer.jsx
export default function NeonFooter({ contact = {}, policies = {} }) {
return (
<footer className="bg-surface-dark text-ink-on-dark py-10 text-center">
<p>{contact.companyName}</p>
</footer>
);
}Step 2: Create the pack index
Export only the slots you override. Slots not listed here fall back to base at runtime.
// src/themes/neon/index.js
import Footer from "./components/Footer";
export default {
id: "neon",
Footer,
};Step 3: Register the pack
Add a lazy import to src/themes/registry.js:
export const registry = {
// … existing packs …
neon: () => import("./neon"),
};Step 4: Assign the pack to a tenant
In src/config/tenants.js:
acme: {
// …
theme: { packId: "neon", fontKey: "poppins", tokens: { "--primary": "#22d3ee" } },
},Step 5: Push config and redeploy
node scripts/push-tenants.mjs --tenant acme
npm run deployKV config changes are live immediately; pack code changes require a redeploy because they ship in the Worker bundle.
Step 6: Validate
npm run check:theme-slotsThen test locally: http://acme.localhost:3000 (if acme.localhost is in the tenant's domains list).
Step 7: Styling inside a pack
- Prefer semantic Tailwind tokens (
bg-surface,text-primary,border-border-soft, etc.) that map to the--*CSS variables. This way the same pack adapts automatically to any tenant'stheme.tokensoverrides. - Avoid hardcoded hex values in pack components.
- For pack-specific CSS variables, add them to the tenant's
theme.tokensor scope with[data-theme="<packId>"](the layout setsdata-theme={packId}on the wrapper div).
---
How Tenant Layout Applies Theming
src/app/[tenant]/[locale]/layout.js does all of this in a single server render:
const tenantCss = buildTenantCss(tenant); // CSS vars + optional @font-face
const preloadFont = fontPreload(tenant); // woff2 path or null
const pack = await getThemePack(tenant.theme?.packId);
const Navbar = pack.Navbar;
const Footer = pack.Footer;The data-theme attribute on the root wrapper div equals the pack id, enabling pack-specific CSS selectors:
<div data-theme="aurora" data-tenant="tabir" data-locale="en" class="site-wrapper …">