Multi-Tenant Theming with Tailwind CSS v4 and CSS Variables
Tailwind v4 tokens compile to real CSS custom properties, so one build can serve every tenant's brand. The override pattern, contrast handling, and the pitfalls.
Tailwind CSS v4 makes per-tenant theming a runtime problem instead of a build problem. Because @theme tokens compile to real CSS custom properties, and utilities reference them through var(), you can give every tenant its own brand colors by overriding a handful of variables on a scoped selector — one build, one bundle, unlimited themes.
This was genuinely hard in v3, where tailwind.config.js values were baked into the compiled CSS. The usual workarounds were shipping a stylesheet per tenant or generating classes for every brand. Neither survives contact with a customer who wants their own hex code.
The mechanism, exactly
Start with tokens in @theme, which is where a v4 project defines its design system:
@import "tailwindcss";
@theme {
--color-primary: #465fff;
--color-primary-600: #3641f5;
}
Compile that with a bg-primary in your markup and you get two things:
:root {
--color-primary: #465fff;
--color-primary-600: #3641f5;
}
.bg-primary {
background-color: var(--color-primary);
}
That second rule is the whole trick. The utility does not contain #465fff — it contains a reference. Override --color-primary anywhere in the cascade and every bg-primary, text-primary, border-primary and ring-primary on the page follows, with no recompile and no extra CSS.
[data-tenant="acme"] {
--color-primary: #0f766e;
--color-primary-600: #0d5f58;
}
Wrap a tenant's UI in <div data-tenant="acme"> and it is themed. The bundle did not grow.
@theme vs @theme inline
This is the detail that costs people an afternoon, and it is worth stating precisely because the two behave differently.
@theme | @theme inline | |
|---|---|---|
| Emits the token as a CSS variable | Yes | No |
| Utility compiles to | var(--color-primary) | the token's value, inlined |
| Override at runtime by setting | --color-primary | whatever variable the value referenced |
Concretely, given @theme inline { --color-brand: var(--raw-brand); }, the utility compiles to color: var(--raw-brand) — the --color-brand variable is never emitted. That is the shadcn/ui pattern: tokens defined in terms of other variables that you swap, with inline collapsing the indirection.
Both support runtime theming; they just give you a different variable to override. For straightforward multi-tenant branding, plain @theme is the simpler path — define the tokens once, override them per tenant. Reach for inline when your tokens are aliases over a separate raw palette.
If you are unsure which your setup produces, compile once and read the output.
.bg-primary { background-color: var(--color-primary) }means you are on the plain-@themepath.
Applying the theme without a flash
The naive approach sets the tenant's variables from JavaScript on mount. That produces the same class of bug as the dark-mode flash: the browser paints the default brand, then repaints the tenant's.
Unlike dark mode, though, you do not need a blocking script — the tenant is knowable on the server, from the subdomain, path segment, or session. So render the variables into the HTML directly.
// app/[tenant]/layout.tsx
import { getTenantTheme } from "@/lib/tenants";
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ tenant: string }>;
}) {
const { tenant } = await params;
const theme = await getTenantTheme(tenant);
return (
<div
style={
{
"--color-primary": theme.primary,
"--color-primary-600": theme.primaryDark,
} as React.CSSProperties
}
>
{children}
</div>
);
}
The variables arrive in the first byte of HTML. There is no flash because there is never a wrong state to correct.
The as React.CSSProperties cast is needed because React's types do not know about arbitrary custom properties. It is safe — React passes any ---prefixed key straight through to the inline style attribute.
When the theme has many tokens
Inline styles get unwieldy past a handful of variables. Emit a scoped stylesheet instead:
export default async function TenantLayout({ children, params }) {
const { tenant } = await params;
const theme = await getTenantTheme(tenant);
const css = Object.entries(theme.tokens)
.map(([token, value]) => `${token}:${value}`)
.join(";");
return (
<>
<style>{`[data-tenant="${tenant}"]{${css}}`}</style>
<div data-tenant={tenant}>{children}</div>
</>
);
}
Two rules make this safe, and neither is optional:
Validate every value before it reaches the stylesheet. A tenant-supplied color goes into a <style> tag, which is an injection surface. Do not interpolate raw user input:
const HEX = /^#[0-9a-f]{6}$/i;
export function safeColor(input: string, fallback: string): string {
return HEX.test(input) ? input : fallback;
}
Validate on write (when the tenant saves their brand color) and on read. Storing a validated value does not guarantee it stayed valid.
Validate the tenant key too. It is interpolated into the selector. Restrict it to /^[a-z0-9-]+$/ — the same constraint a URL slug already has.
Deriving a scale from one color
Tenants supply one brand color, but your components use a scale — primary, primary-600 for hover, primary-50 for tinted backgrounds. Generating that from a single hex is the practical problem.
Modern CSS does it natively, no build step and no color library:
[data-tenant] {
--color-primary-50: color-mix(in oklab, var(--color-primary) 10%, white);
--color-primary-600: color-mix(in oklab, var(--color-primary) 85%, black);
--color-primary-700: color-mix(in oklab, var(--color-primary) 70%, black);
}
Mix in oklab rather than srgb. sRGB interpolation darkens through grey and produces muddy mid-tones; oklab is perceptually uniform and keeps the hue intact. color-mix() is supported across current browsers.
The trade-off is honest: a mechanically derived scale is acceptable for every hue, not optimal for any. If tenants are paying for pixel-perfect brand control, let them supply the full scale and fall back to derivation when they do not.
Contrast is the part that breaks
This is the failure mode nobody plans for. text-white on bg-primary is fine at #465fff and unreadable at #fbbf24. Let tenants pick their own color and some of them will pick yellow.
Solve it in CSS with a contrast-aware foreground token:
[data-tenant] {
--color-on-primary: oklch(from var(--color-primary) clamp(0, (l - 0.6) * -100, 1) 0 0);
}
That reads the lightness of the brand color and flips the foreground to black or white around a threshold. Then components use text-on-primary rather than a hardcoded text-white, and a light brand color gets dark text automatically.
If oklch(from ...) relative color syntax is not available in your support matrix, compute the same decision on the server when the tenant saves their color and store onPrimary as a normal token. Less elegant, works everywhere.
Either way, the rule is the same: never hardcode text-white on a tenant-controlled background. That single habit prevents most theming accessibility bugs.
Combining tenant themes with dark mode
The two are independent, and they compose cleanly because they are both just cascade. Scope tenant tokens to the tenant selector and let the dark variant override what it needs:
[data-tenant="acme"] {
--color-primary: #0f766e;
--color-surface: #ffffff;
}
.dark [data-tenant="acme"] {
--color-surface: #101828;
/* brand color usually stays; surfaces change */
}
The common mistake is theming surfaces per tenant as aggressively as brand colors. Tenants care that the button is their green. They do not need a bespoke grey scale, and giving them one doubles your contrast testing.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Using @theme inline for tokens you override | Runtime override does nothing | Use plain @theme, or override the underlying variable |
Setting variables in a useEffect | Brand color flashes on load | Render them server-side into the HTML |
Interpolating unvalidated color into <style> | CSS injection | Regex-validate hex on write and read |
Editing @theme and not restarting dev | New utilities silently missing | Restart the dev server — v4 + Turbopack does not hot-reload @theme |
Hardcoding text-white on brand backgrounds | Unreadable for light brand colors | A contrast-aware --color-on-primary token |
color-mix in srgb | Muddy, desaturated hover states | Mix in oklab |
Overriding on :root from client code | Theme leaks across tenants in one session | Scope to the tenant element |
| Per-tenant compiled stylesheets | Build time grows linearly with customers | One build; override variables at runtime |
The dev-server one deserves emphasis because it wastes the most time: Tailwind v4 with Turbopack does not hot-reload @theme changes. Add a token, see nothing, assume your syntax is wrong, and lose twenty minutes. Restart first.
Frequently asked questions
Does this add CSS for every tenant? No, and that is the point. The utilities are compiled once. Each tenant adds only its variable declarations — a few hundred bytes — instead of a stylesheet. Ten tenants and ten thousand cost the same in compiled CSS.
Can I do this in Tailwind v3?
Partially, and awkwardly. You define colors as rgb(var(--x) / <alpha-value>) in the JS config and manage the channel-triple variables yourself. It works, and it is enough friction that most v3 projects shipped per-tenant stylesheets instead. v4's CSS-first @theme makes it the default behaviour rather than a trick.
What about theming from a database? That is the normal case. Fetch the tenant's tokens in a Server Component, validate them, and render them into the layout. Cache the lookup per tenant — it changes rarely, and you do not want a database round trip on every request just to learn a hex code.
Do the variables work in arbitrary values?
Yes. bg-[var(--color-primary)] and shadow-[0_0_0_3px_var(--color-primary)] both resolve at runtime like any other utility, which is useful for one-off styles that have no token.
How do I test this? Snapshot the compiled CSS to catch tokens that stopped being emitted, and render a contrast check across a set of representative brand colors — include a yellow and a very dark blue. Those two catch most of what breaks.
Starting from a themed template
Everything above assumes a token layer already exists. Building one from scratch is the slow part — not the color-mix calls, but deciding which tokens exist and making every component use them instead of literal colors.
Our Tailwind v4 templates ship that layer done: ASoc Admin defines its full primary and grey scales in @theme with every component wired to the tokens, so making it multi-tenant is the variable override above rather than a refactor. ASoc Crest is the same system with fewer screens.
For more on the v4 model underneath this, see the Tailwind v4 migration guide. Browse the Tailwind admin templates and Tailwind landing page templates for starting points.
