DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Building a Custom Tailwind CSS Theme System for Multi-Tenant Branding: Dynamic Colors Without CSS-in-JS

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:

  1. Hardcoded themes – You ship 3–5 pre-made color palettes. Tenants pick one. Boring. Dead on arrival for enterprise sales.
  2. 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.
  3. 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:

  1. Define CSS custom properties at the :root level (or per-tenant root, if needed).
  2. Map those variables into Tailwind's theme in your config.
  3. Update variables dynamically from your frontend or inject them from your backend.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 workbg-primary-500/50 just works because

Top comments (0)