Building a Custom Tailwind CSS Theme System for Multi-Tenant Branding: Dynamic Colors Without CSS-in-JS
When I built CitizenApp's first tenant branding feature, I made the mistake every SaaS founder makes: I reached for CSS-in-JS. Emotion, styled-components, the whole nine yards. Within three months, I was shipping 340KB of theme bundles per tenant, bundle-splitting was a nightmare, and dark mode switching caused a flash of unstyled content that users could measure with a stopwatch.
The real solution was sitting in front of me the entire time: Tailwind's theme configuration + CSS custom properties. No runtime overhead. No JavaScript parsing. No bundle bloat. Just pure CSS variables that update instantly across your entire app, whether it's a static Astro page or a React 19 island.
Here's how I fixed it.
The Problem with Traditional Multi-Tenant Theming
Most SaaS apps handle branding one of three ways, and all of them suck:
- Hardcoded themes – You ship 3–5 pre-made color palettes. Tenants pick one. Boring. Dead on arrival for enterprise sales.
- CSS-in-JS generators – You generate theme bundles per tenant and serve them from a CDN. Works, but adds deployment complexity, cache invalidation headaches, and you're injecting CSS at runtime.
-
Inline styles everywhere –
<div style={{ color: tenantColor }}>. This is the path to CSS hell. No cascade, no design system, no DX.
I prefer a fourth way: CSS custom properties + Tailwind's theme config, applied at the :root level. When a tenant switches their brand color, you're updating a single CSS variable. The browser recalculates. Done.
How It Works: The Architecture
The pattern is simple:
-
Define CSS custom properties at the
:rootlevel (or per-tenant root, if needed). - Map those variables into Tailwind's theme in your config.
- Update variables dynamically from your frontend or inject them from your backend.
- Use Tailwind utilities normally – they reference the variables under the hood.
The key insight: Tailwind's theme() function can reference arbitrary CSS variables. You're not reinventing the wheel. You're just making the wheel's size configurable.
The Implementation
1. Tailwind Configuration
First, your tailwind.config.ts:
import type { Config } from 'tailwindcss'
export default {
content: ['./src/**/*.{astro,tsx,ts}'],
theme: {
extend: {
colors: {
// Brand colors sourced from CSS variables
primary: {
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
},
accent: {
500: 'rgb(var(--color-accent-500) / <alpha-value>)',
600: 'rgb(var(--color-accent-600) / <alpha-value>)',
},
},
spacing: {
'gutter': 'var(--spacing-gutter)',
'section': 'var(--spacing-section)',
},
},
},
} satisfies Config
Notice the <alpha-value> syntax? That lets you use opacity modifiers like bg-primary-500/50 without any JavaScript.
2. Global CSS with Defaults
Your globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Default theme (light) */
--color-primary-50: 245 245 245;
--color-primary-100: 229 229 229;
--color-primary-500: 59 130 246;
--color-primary-600: 37 99 235;
--color-primary-900: 30 41 59;
--color-accent-500: 168 85 247;
--color-accent-600: 147 51 234;
--spacing-gutter: 1.5rem;
--spacing-section: 4rem;
}
/* Dark mode override */
@media (prefers-color-scheme: dark) {
:root {
--color-primary-50: 15 23 42;
--color-primary-100: 30 41 59;
--color-primary-500: 96 165 250;
--color-primary-600: 59 130 246;
--color-primary-900: 241 245 250;
}
}
This gives you a sensible baseline. No tenant customization yet—just the system itself.
3. The Theme Provider (React)
Now the dynamic part. This is where your tenant's custom colors actually get applied:
// ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
interface TenantTheme {
primaryColor: string // hex
accentColor: string // hex
darkMode: boolean
spacing: {
gutter: string // CSS value
section: string
}
}
const ThemeContext = createContext<TenantTheme | null>(null)
export function ThemeProvider({
children,
theme,
}: {
children: React.ReactNode
theme: TenantTheme
}) {
useEffect(() => {
// Convert hex to RGB so we can use opacity modifiers
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(
result[3],
16,
)}`
: '59 130 246' // fallback
}
const root = document.documentElement
const primaryRgb = hexToRgb(theme.primaryColor)
const accentRgb = hexToRgb(theme.accentColor)
// Set CSS variables
root.style.setProperty('--color-primary-500', primaryRgb)
root.style.setProperty('--color-accent-500', accentRgb)
root.style.setProperty('--spacing-gutter', theme.spacing.gutter)
root.style.setProperty('--spacing-section', theme.spacing.section)
// Handle dark mode
if (theme.darkMode) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}, [theme])
return (
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
4. Fetching Tenant Theme from Your API
In your root layout (Astro) or app shell (React):
// FastAPI endpoint
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import Tenant
router = APIRouter()
@router.get("/api/tenants/me/theme")
async def get_tenant_theme(
tenant_id: str = Depends(get_current_tenant),
db: Session = Depends(get_db)
):
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404)
return {
"primaryColor": tenant.brand_primary_color or "#3B82F6",
"accentColor": tenant.brand_accent_color or "#A855F7",
"darkMode": tenant.dark_mode_enabled,
"spacing": {
"gutter": tenant.spacing_gutter or "1.5rem",
"section": tenant.spacing_section or "4rem"
}
}
Then in your React app init:
// App.tsx
export default function App() {
const [theme, setTheme] = useState<TenantTheme | null>(null)
useEffect(() => {
fetch('/api/tenants/me/theme')
.then(r => r.json())
.then(setTheme)
}, [])
if (!theme) return null
return (
<ThemeProvider theme={theme}>
<YourApp />
</ThemeProvider>
)
}
5. Using It
Now you just... use Tailwind normally:
// Button.tsx
export function Button() {
return (
<button className="bg-primary-500 hover:bg-primary-600 text-white px-gutter py-2 rounded">
Click me
</button>
)
}
When the tenant's theme updates, that button's background color updates instantly. No rebuild. No bundle swap. No flash.
Why This Beats CSS-in-JS
- Zero runtime parsing – CSS variables are native. The browser handles them natively since IE11 (and you're not supporting IE11 anyway).
- Works across static + dynamic – An Astro static page and a React island on the same domain both see the same CSS variables.
- No bundle size penalty – You're not shipping a theme generator or theme object per tenant.
-
Dark mode is free – You just toggle a class and use
@media (prefers-color-scheme: dark)to swap variables. -
Opacity modifiers work –
bg-primary-500/50just works because
Top comments (0)