Runtime CSS Variables Per Tenant in Tailwind CSS v4
A separate Tailwind build for every white-label customer scales poorly once a hundred tenants want to use the same application with their own color scheme. With a single CSS bundle and server side injected CSS variables based on the hostname, white-label theming without a rebuild per brand becomes possible, directly on top of Tailwind CSS v4 design tokens.
Table of Contents
- 1. The scaling problem of build-time multi-brand
- 2. The single bundle strategy
- 3. Tenant detection based on the hostname
- 4. Injecting CSS variables at runtime
- 5. Security: reliably avoiding CSS injection
- 6. @theme as the fallback foundation
- 7. A caching strategy per tenant
- 8. Admin interface with live preview
- 9. Build-time versus runtime injection compared
- 10. Summary
- 11. FAQ
1. The scaling problem of build-time multi-brand
The classic approach to white-label theming generates a separate Tailwind build for every brand, with its own configuration file, its own @theme block, and its own compiled CSS bundle. For five or ten brands this works well, because the extra build effort still fits comfortably into the CI pipeline. But once a SaaS product needs to serve a hundred or a thousand tenants with individual color schemes, this approach becomes a bottleneck.
Every new brand means a new build job, a new deployment pipeline run, and an additional CSS bundle that must be hosted and delivered separately. If a tenant changes just a single accent color, the entire build process runs again, including Tailwind compilation, minification, and cache invalidation. For a product with frequent, small branding changes, white-label theming through separate builds is unnecessarily slow and resource intensive.
The way out is not an even faster build pipeline, but a fundamentally different architecture: a single compiled CSS bundle for every tenant that only receives its actual color values at runtime, through CSS custom properties. White-label theming thereby turns from a build-time problem into a pure data problem that can be solved per request.
2. The single bundle strategy
The core idea behind runtime based white-label theming is simple: Tailwind CSS v4 compiles exactly once, with default values for every theme dependent design token in the @theme block. These default values act as a visible fallback if no tenant specific configuration exists for whatever reason, for example for a new customer during onboarding.
For the actual brand look, a small, dynamically generated CSS rule overrides the relevant custom properties directly in the :root selector, delivered as an inline <style> block in the HTML head. This block is generated server side from the tenant configuration in the database on every request, while the large, actual Tailwind CSS bundle comes unchanged from cache or CDN.
The decisive advantage of this split: the large, static bundle benefits from aggressive browser and CDN caching over very long periods, because it never changes, while the small, tenant specific overlay block spans only a few hundred bytes and can still be generated fresh on every request without noticeable latency.
/* Base Tailwind v4 tokens, compiled once for all tenants */
@theme {
--color-brand-primary: #0ea5e9;
--color-brand-secondary: #0c4a6e;
--color-brand-surface: #ffffff;
--font-brand-heading: "Inter", sans-serif;
}
/* Components consume the tokens indirectly, never a hardcoded hex value */
.btn-primary {
background-color: var(--color-brand-primary);
font-family: var(--font-brand-heading);
}
3. Tenant detection based on the hostname
For the server to know which brand applies to an incoming request, white-label theming needs reliable tenant detection. The most common approach uses the Host header of the HTTP request, either through a dedicated subdomain per tenant, for example customer-a.product.com, or through a fully custom domain the customer brings themselves and points to the platform via CNAME.
The hostname is matched against a tenant table in the database, which alongside the tenant ID also holds the branding configuration as a JSON structure with color values, logo URL, and font. This lookup should be cached consistently, for example in Redis with a short time to live, because otherwise it triggers an additional database query on every single request, which adds up across many concurrent users.
For white-label theming in a Magento or Hyvä context, the same mechanism can be mapped onto a website or store view code that is already part of the existing multi-site architecture. The advantage: tenant detection does not need to be reinvented, it reuses a structure already present for multi-site operation anyway.
<?php
declare(strict_types=1);
namespace Mironsoft\WhiteLabel\Service;
/**
* Resolves tenant branding config based on the request hostname.
*/
final class TenantThemeResolver
{
public function __construct(
private readonly TenantConfigCache $cache,
private readonly TenantRepositoryInterface $tenantRepository,
) {
}
/**
* Looks up the tenant branding tokens for a given hostname.
*
* @param string $host
* @return array{primary: string, secondary: string, surface: string, font: string}
*/
public function resolve(string $host): array
{
$cached = $this->cache->get($host);
if ($cached !== null) {
return $cached;
}
$tenant = $this->tenantRepository->findByHost($host);
$tokens = $tenant?->getBrandingTokens() ?? $this->defaultTokens();
$this->cache->set($host, $tokens, 300);
return $tokens;
}
/**
* Fallback tokens matching the base @theme block.
*
* @return array{primary: string, secondary: string, surface: string, font: string}
*/
private function defaultTokens(): array
{
return [
'primary' => '#0ea5e9',
'secondary' => '#0c4a6e',
'surface' => '#ffffff',
'font' => 'Inter',
];
}
}
4. Injecting CSS variables at runtime
With the resolved tenant tokens, the application generates a compact inline <style> block that overrides only the custom properties differing from the default @theme block. This block is placed directly in the <head> before the large Tailwind bundle, so the cascade automatically prefers the overridden values without requiring extra selector specificity.
Important for consistent white-label theming: the generated block contains exclusively custom property declarations, never full component rules. That keeps the overlay minimal, usually under a kilobyte, and ensures every change to the component structure is still maintained centrally in the large Tailwind bundle, instead of being duplicated across scattered tenant overrides.
The HTML output itself remains server rendered and cacheable, as long as the cache key includes the hostname. For frameworks with client side rendering, the same mechanism can be implemented through an early fetch of the tenant configuration and a server side pre rendered style tag in the initial HTML, to avoid a brief flash of wrong colors on first load.
<head>
<!-- Generated per request based on the resolved tenant -->
<style id="tenant-theme-overrides">
:root {
--color-brand-primary: #16a34a;
--color-brand-secondary: #14532d;
--color-brand-surface: #f0fdf4;
--font-brand-heading: "Poppins", sans-serif;
}
</style>
<!-- Large, static Tailwind bundle, identical for every tenant -->
<link rel="stylesheet" href="/assets/app.css">
</head>
5. Security: reliably avoiding CSS injection
As soon as user data, even if it only comes from customer administrators, gets embedded directly into a <style> tag, a potential CSS injection risk arises. A maliciously entered value like #000; } body { display: none; } /* could in theory break out of the actual color value and inject arbitrary CSS. White-label theming without strict validation of input values is therefore a security risk, not a purely cosmetic feature.
The most reliable protection is a strict whitelist of the allowed value format: only valid hex color codes, RGB function notations, or a limited list of allowed font names may enter the configuration at all, checked with a regular expression both when saving in the admin interface and again when rendering the style block. Any value that does not match this pattern is discarded and replaced with the fallback value from the base @theme block.
An additional safeguard is a Content Security Policy with a nonce for the inline style block, if the project already uses CSP for scripts, as is common with Hyvä's CSP module. That way white-label theming stays secure even if a single validation layer were to fail.
6. @theme as the fallback foundation
Under this approach, the base @theme block remains more than just a starting value, it is the single source of truth for the structure of the design tokens. Every tenant can only override values that already exist as a custom property in the base block, never introduce new, unknown variable names. That keeps white-label theming predictable, because developers writing components always work against the same, fixed set of tokens.
For tenants without their own configuration, for example during a trial account or right after registration, the default values from the base block apply automatically. That prevents a broken look for new customers who have not yet set their own colors, and makes onboarding independent from an immediately complete branding configuration.
In practice, a clear separation proves useful between tokens that are white-label capable, such as primary and secondary color, surface color, and font, and tokens that stay identical for every tenant, such as spacing, radii, or font sizes. Only the white-label capable tokens go through the runtime injection mechanism, all others stay firmly anchored in the compiled bundle.
7. A caching strategy per tenant
Efficient caching is the key to making sure white-label theming with runtime injection does not become a performance disadvantage compared to build-time approaches. The large, static Tailwind bundle gets a cache control header with a very long validity period and a hash in the filename, so new deployments automatically produce a new URL without requiring old caches to be invalidated.
The HTML response with the embedded tenant style block, on the other hand, needs a more nuanced caching approach. A CDN cache key that includes the hostname ensures every tenant receives their own, correctly colored version of the page from cache, while the database lookup for the tenant configuration itself is cached with a short time to live in Redis or a comparable in memory store.
If a tenant changes their brand color through the admin interface, only the cache entry for that one hostname needs to be invalidated, never the entire CSS bundle or the caches of other tenants. That is a structural advantage over build-time approaches, where a change always triggers a full new build cycle.
# Static bundle: long-lived, immutable, hash in filename
curl -I https://cdn.product.com/assets/app.a1b2c3d4.css
# Cache-Control: public, max-age=31536000, immutable
# HTML response: cached per tenant hostname via CDN cache key
curl -I -H "Host: customer-a.product.com" https://product.com/
# Cache-Control: private, max-age=60
# Vary: Host
8. Admin interface with live preview
For customers who should configure their own white-label theming without requesting developer support, a small admin interface with color pickers and a live preview is worthwhile. Alpine.js fits well here, because the chosen color values can be bound directly through x-bind:style to CSS custom properties of a preview container, without requiring a full page reload.
The live preview simulates exactly the same mechanism that later applies server side for the real page request: custom properties are set in the :root context of the preview container, while the same Tailwind component classes as in the real application are used. That way the customer sees exactly the result that will later appear live on their subdomain before saving, with no discrepancy between preview and production.
On save, every entered value goes through the same validation described in section five before it lands in the tenant configuration. Only after successful validation is the corresponding cache entry invalidated, so faulty input never appears live on the tenant's page.
// Alpine.js component for the tenant branding admin preview
function tenantThemeEditor() {
return {
primary: '#0ea5e9',
secondary: '#0c4a6e',
get previewStyle() {
return `--color-brand-primary: ${this.primary}; ` +
`--color-brand-secondary: ${this.secondary};`;
},
async save() {
const response = await fetch('/admin/tenant/theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
primary: this.primary,
secondary: this.secondary,
}),
});
if (!response.ok) {
alert('Validation failed, colors were not saved.');
}
},
};
}
9. Build-time versus runtime injection compared
Both approaches solve the same fundamental problem, but differ considerably in scalability, iteration speed, and operational effort. The following table compares both strategies for white-label theming directly.
| Criterion | Build-time multi-config | Runtime injection | Assessment |
|---|---|---|---|
| Scaling to 100+ tenants | Build time grows linearly | Constant build time | Runtime clearly superior |
| Changing one color | Requires a new build cycle | Instant, no deployment | Much faster iteration |
| Number of CSS bundles | One per tenant | One shared bundle | Less hosting overhead |
| Self-service customer configuration | Requires a developer deploy | Possible via admin UI | Self-service achievable |
| Security effort | Low, values fixed in the build | Validation strictly required | Runtime needs more diligence |
The comparison shows that white-label theming via runtime injection plays out its strengths especially when the number of tenants is large or customers should manage their own colors. For very small multi-brand projects with a few, rarely changing brands, a classic build-time approach can still be the simpler choice because of its lower security overhead.
Mironsoft
Tailwind CSS v4, multi-tenant architecture, and SaaS platforms
A hundred brands, one single CSS bundle?
We build runtime theming architectures with Tailwind CSS v4 that serve white-label customers without a separate build per brand, including secure validation and an admin interface.
Architecture consulting
Migrating from build-time multi-brand to single-bundle runtime theming
Secure implementation
Building validation, caching strategy, and CSP protection production ready
Self-service UI
Admin interface with live preview for customers without developer support
10. Summary
White-label theming without a rebuild per brand rests on a clear separation between a single, immutable Tailwind bundle and a small, tenant specific CSS variable overlay generated server side based on the hostname. Tenant detection uses the Host header, the actual color logic stays anchored in the base @theme block, and only the tokens that are actually white-label capable go through the runtime injection mechanism.
Security is not a side note in this approach: every entered color value must be strictly validated against an allowed format before it enters an inline style tag. Combined with a clean per hostname caching strategy, white-label theming scales this way to hundreds or thousands of tenants without build time or hosting effort multiplying with every new brand.
White-Label Theming Without a Rebuild — The Essentials at a Glance
Single bundle principle
One Tailwind build for all tenants, color values arrive exclusively through CSS variables at runtime.
Hostname lookup
The Host header identifies the tenant, cached in Redis with a short time to live.
Validation as a requirement
Only strictly validated hex values or font names may enter the inline style block.
Caching per hostname
Static bundle cached long term, HTML response separated per tenant with Vary: Host.