Dynamic Theming for Multi-Tenant Apps
How I built a theming system that lets each tenant customize their branding without touching CSS.
Dynamic Theming for Multi-Tenant Apps
How I built a theming system that lets each tenant customize their branding without touching CSS.
Dynamic Theming for Multi-Tenant Apps
When you’re building a white-label product, every tenant wants it to feel like their app. That means custom colors, logos, and branding — without shipping separate CSS bundles or maintaining per-tenant stylesheets.
Here’s how I approached it.
CSS custom properties as the foundation
The entire UI is built on CSS variables. A single set of design tokens controls the look and feel of the whole app:
:root {
--brand-primary: #2563eb;
--brand-secondary: #1e40af;
--brand-accent: #3b82f6;
--radius: 8px;
--font-body: 'Inter', sans-serif;
} When a tenant loads their page, the server injects their brand config as CSS variable overrides. No class swapping, no conditional imports — just property values changing at the root level.
Theme presets vs. custom colors
Not every shop owner wants to pick hex codes. I built a set of 8 curated presets — themes like “Midnight,” “Forest,” “Ember” — that work well out of the box. Each preset is just a map of variable values.
For shops that want full control, there’s a color picker in the admin dashboard. The tricky part is making sure their choices are accessible. A bright yellow primary on a white background is technically “their brand” but unreadable. I added a contrast checker that nudges them toward better combinations without blocking the save.
Where it gets interesting
Some properties cascade nicely — change --brand-primary and buttons, links, and focus rings all update automatically. But others need more thought. Hover states, active states, and shadows all derive from the primary color. I use color-mix() to generate lighter and darker variants on the fly:
.button:hover {
background: color-mix(in srgb, var(--brand-primary) 85%, black);
}
.button:active {
background: color-mix(in srgb, var(--brand-primary) 75%, black);
} This means I never hardcode a hover color. It always derives from whatever the tenant chose.
Performance
The theme loads as inline CSS in the document head — no extra network request, no flash of unstyled content. The theme data is cached at the edge, so subsequent visits resolve instantly.
The total cost is about 200 bytes of inline CSS per page load. Worth it.
Lessons learned
- Start with presets. Most users don’t want full customization — they want something that looks good with one click.
- Validate accessibility. If you let users pick colors, check contrast ratios before saving.
- Derive, don’t define. The fewer values a tenant needs to set, the more consistent the result.
- Cache the theme. Resolving tenant config on every request adds up. Cache it.
Working on a multi-tenant product? I’d love to help.