--- name: white-label description: MUI multi-theme and white-label systems — nested ThemeProvider, dynamic theme switching, tenant-specific branding, and theme composition triggers: - white-label - multi-theme - multi-tenant - nested ThemeProvider - dynamic theme - tenant branding - theme switching allowed-tools: - Read - Glob - Grep - Write - Edit globs: - "*.tsx" - "*.ts" - "theme*.ts" --- # MUI White-Label and Multi-Theme Systems ## Nested ThemeProvider MUI's `ThemeProvider` can be nested. Inner providers merge with or override the outer theme. Use this to scope different visual treatments to different sections of the app. ```tsx import { ThemeProvider, createTheme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; const mainTheme = createTheme({ palette: { primary: { main: '#1976d2' }, background: { default: '#fafafa' }, }, }); // Admin sidebar uses a dark theme, scoped to its subtree const adminSidebarTheme = createTheme({ palette: { mode: 'dark', primary: { main: '#90caf9' }, background: { default: '#1e1e1e', paper: '#2d2d2d' }, }, }); export function AppShell() { return ( {/* Dark sidebar — scoped theme */} Admin {/* Main content — inherits mainTheme */} Welcome ); } ``` ### Nested Theme with Callback (Merge Instead of Replace) Pass a function to `ThemeProvider` to receive the outer theme and merge selectively: ```tsx createTheme({ ...outerTheme, palette: { ...outerTheme.palette, primary: { main: '#e91e63' }, // override only primary }, }) } > ``` --- ## Dynamic Theme Loading Load theme configuration from an API or database at runtime. This is the foundation of any white-label system. ```tsx import { ThemeProvider, createTheme, type ThemeOptions } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import CircularProgress from '@mui/material/CircularProgress'; import Box from '@mui/material/Box'; import { useState, useEffect, useMemo, type ReactNode } from 'react'; interface BrandConfig { primaryColor: string; secondaryColor: string; fontFamily: string; borderRadius: number; logoUrl: string; mode: 'light' | 'dark'; } function buildThemeOptions(brand: BrandConfig): ThemeOptions { return { palette: { mode: brand.mode, primary: { main: brand.primaryColor }, secondary: { main: brand.secondaryColor }, }, typography: { fontFamily: brand.fontFamily, }, shape: { borderRadius: brand.borderRadius, }, }; } async function fetchBrandConfig(tenantId: string): Promise { const res = await fetch(`/api/tenants/${tenantId}/brand`); if (!res.ok) throw new Error(`Failed to load brand config for ${tenantId}`); return res.json(); } interface DynamicThemeProviderProps { tenantId: string; children: ReactNode; } export function DynamicThemeProvider({ tenantId, children }: DynamicThemeProviderProps) { const [brandConfig, setBrandConfig] = useState(null); const [error, setError] = useState(null); useEffect(() => { fetchBrandConfig(tenantId) .then(setBrandConfig) .catch((err) => setError(err.message)); }, [tenantId]); const theme = useMemo( () => (brandConfig ? createTheme(buildThemeOptions(brandConfig)) : null), [brandConfig], ); if (error) return Theme load failed: {error}; if (!theme) { return ( ); } return ( {children} ); } ``` --- ## Theme Composition Use `deepmerge` (shipped with MUI as `@mui/utils/deepmerge`) to layer tenant overrides on top of a base theme. This ensures tenants only override what they need and inherit everything else. ```tsx import { createTheme, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; // Shared base — every tenant gets this as the foundation const baseThemeOptions: ThemeOptions = { typography: { fontFamily: '"Inter", "Helvetica", "Arial", sans-serif', h1: { fontSize: '2.5rem', fontWeight: 700 }, h2: { fontSize: '2rem', fontWeight: 600 }, button: { textTransform: 'none', fontWeight: 600 }, }, shape: { borderRadius: 8 }, components: { MuiButton: { defaultProps: { disableElevation: true }, styleOverrides: { root: { borderRadius: 8, padding: '8px 20px' }, }, }, MuiCard: { defaultProps: { elevation: 0 }, styleOverrides: { root: { border: '1px solid', borderColor: 'rgba(0,0,0,0.12)' }, }, }, MuiTextField: { defaultProps: { variant: 'outlined', size: 'small' }, }, }, }; // Tenant-specific overrides — only the diff const acmeOverrides: ThemeOptions = { palette: { primary: { main: '#ff5722' }, secondary: { main: '#ff9800' }, }, typography: { fontFamily: '"Poppins", sans-serif', }, components: { MuiButton: { styleOverrides: { root: { borderRadius: 24 }, // pill buttons for Acme }, }, }, }; const globexOverrides: ThemeOptions = { palette: { mode: 'dark', primary: { main: '#00bcd4' }, secondary: { main: '#7c4dff' }, }, shape: { borderRadius: 4 }, }; // Compose: base + tenant overrides via deepmerge export function createTenantTheme(overrides: ThemeOptions) { const merged = deepmerge(baseThemeOptions, overrides); return createTheme(merged); } // Usage const acmeTheme = createTenantTheme(acmeOverrides); const globexTheme = createTenantTheme(globexOverrides); ``` --- ## Tenant-Specific Branding A token-based approach: load brand colors, fonts, logos, and favicon from a config object. This separates brand identity from theme mechanics. ```tsx import { createTheme, type Theme, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; // Brand tokens — everything that varies per tenant export interface BrandTokens { id: string; name: string; primaryColor: string; secondaryColor: string; errorColor?: string; warningColor?: string; successColor?: string; fontFamily: string; headingFontFamily?: string; borderRadius: number; logoUrl: string; logoHeight: number; // px faviconUrl: string; mode: 'light' | 'dark'; customShadows?: boolean; // use flat design (no shadows) } const baseThemeOptions: ThemeOptions = { typography: { button: { textTransform: 'none' }, }, components: { MuiButton: { defaultProps: { disableElevation: true } }, MuiTextField: { defaultProps: { variant: 'outlined', size: 'small' } }, }, }; export function createBrandTheme(tokens: BrandTokens): Theme { const brandOverrides: ThemeOptions = { palette: { mode: tokens.mode, primary: { main: tokens.primaryColor }, secondary: { main: tokens.secondaryColor }, ...(tokens.errorColor && { error: { main: tokens.errorColor } }), ...(tokens.warningColor && { warning: { main: tokens.warningColor } }), ...(tokens.successColor && { success: { main: tokens.successColor } }), }, typography: { fontFamily: tokens.fontFamily, h1: { fontFamily: tokens.headingFontFamily ?? tokens.fontFamily }, h2: { fontFamily: tokens.headingFontFamily ?? tokens.fontFamily }, h3: { fontFamily: tokens.headingFontFamily ?? tokens.fontFamily }, }, shape: { borderRadius: tokens.borderRadius }, ...(tokens.customShadows === false && { shadows: Array(25).fill('none') as Theme['shadows'], }), }; return createTheme(deepmerge(baseThemeOptions, brandOverrides)); } // React context to expose brand tokens (logo, favicon, etc.) outside the theme import { createContext, useContext, type ReactNode } from 'react'; const BrandContext = createContext(null); export function useBrand(): BrandTokens { const brand = useContext(BrandContext); if (!brand) throw new Error('useBrand must be used within BrandProvider'); return brand; } export function BrandProvider({ tokens, children }: { tokens: BrandTokens; children: ReactNode }) { return {children}; } ``` Usage in a header component: ```tsx import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Box from '@mui/material/Box'; import { useBrand } from './brand'; export function BrandedHeader() { const brand = useBrand(); return ( ); } ``` --- ## CSS Variables Approach MUI v5.1+ supports `CssVarsProvider` which uses CSS custom properties for palette values. This enables theme switching without React re-renders — the browser just swaps variable values. ```tsx import { Experimental_CssVarsProvider as CssVarsProvider, experimental_extendTheme as extendTheme, useColorScheme, } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import DarkModeIcon from '@mui/icons-material/DarkMode'; import LightModeIcon from '@mui/icons-material/LightMode'; import type { ReactNode } from 'react'; // extendTheme replaces createTheme when using CssVarsProvider const theme = extendTheme({ colorSchemes: { light: { palette: { primary: { main: '#1976d2' }, background: { default: '#fafafa', paper: '#ffffff' }, }, }, dark: { palette: { primary: { main: '#90caf9' }, background: { default: '#121212', paper: '#1e1e1e' }, }, }, }, typography: { fontFamily: '"Inter", sans-serif', button: { textTransform: 'none' }, }, }); // Toggle component — switches mode via CSS variables, zero re-render function ModeToggle() { const { mode, setMode } = useColorScheme(); return ( setMode(mode === 'light' ? 'dark' : 'light')} aria-label={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`} > {mode === 'light' ? : } ); } export function CssVarsApp({ children }: { children: ReactNode }) { return ( {children} ); } ``` ### Accessing CSS Variables in sx ```tsx ``` ### CssVarsProvider with Multi-Tenant Combine `extendTheme` with tenant overrides: ```tsx import { experimental_extendTheme as extendTheme } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; const baseExtended = { colorSchemes: { light: { palette: { primary: { main: '#1976d2' } } }, dark: { palette: { primary: { main: '#90caf9' } } }, }, }; function createTenantCssVarsTheme(overrides: Record) { return extendTheme(deepmerge(baseExtended, overrides)); } // Tenant override — only changes primary color for both schemes const acmeCssVarsTheme = createTenantCssVarsTheme({ colorSchemes: { light: { palette: { primary: { main: '#ff5722' } } }, dark: { palette: { primary: { main: '#ff8a65' } } }, }, }); ``` --- ## Theme Registry Pattern Map tenant IDs to theme objects. Useful when you have a known set of tenants at build time, or when you cache API-loaded themes. ```tsx import { createTheme, type Theme, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; const baseOptions: ThemeOptions = { typography: { button: { textTransform: 'none' } }, shape: { borderRadius: 8 }, }; // Registry: tenant ID -> theme options (only the diff from base) const tenantThemeOverrides: Record = { acme: { palette: { primary: { main: '#ff5722' }, secondary: { main: '#ff9800' } }, typography: { fontFamily: '"Poppins", sans-serif' }, }, globex: { palette: { mode: 'dark', primary: { main: '#00bcd4' } }, shape: { borderRadius: 4 }, }, initech: { palette: { primary: { main: '#4caf50' }, secondary: { main: '#8bc34a' } }, typography: { fontFamily: '"Roboto Mono", monospace' }, }, }; // Cache to avoid re-creating themes on every render const themeCache = new Map(); export function getTenantTheme(tenantId: string): Theme { const cached = themeCache.get(tenantId); if (cached) return cached; const overrides = tenantThemeOverrides[tenantId]; if (!overrides) { console.warn(`No theme registered for tenant "${tenantId}", using base theme`); const fallback = createTheme(baseOptions); themeCache.set(tenantId, fallback); return fallback; } const theme = createTheme(deepmerge(baseOptions, overrides)); themeCache.set(tenantId, theme); return theme; } // Dynamic registration for API-loaded tenants export function registerTenantTheme(tenantId: string, overrides: ThemeOptions): void { tenantThemeOverrides[tenantId] = overrides; themeCache.delete(tenantId); // invalidate cache } ``` Usage: ```tsx import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { getTenantTheme } from './theme-registry'; function App({ tenantId }: { tenantId: string }) { const theme = getTenantTheme(tenantId); return ( {/* app content */} ); } ``` --- ## Component Variant Overrides per Tenant Tenants may need different component appearances beyond colors — pill buttons for one brand, square for another, outlined defaults for a third. ```tsx import { createTheme, type ThemeOptions, type Components, type Theme } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; // Each tenant defines component overrides type TenantComponents = Components>; const acmeComponents: TenantComponents = { MuiButton: { defaultProps: { variant: 'contained', disableElevation: true }, styleOverrides: { root: { borderRadius: 24, padding: '10px 28px', fontWeight: 700 }, containedPrimary: { background: 'linear-gradient(45deg, #ff5722, #ff9800)', '&:hover': { background: 'linear-gradient(45deg, #e64a19, #f57c00)' }, }, }, }, MuiCard: { defaultProps: { elevation: 0 }, styleOverrides: { root: { borderRadius: 16, border: '2px solid', borderColor: 'rgba(255, 87, 34, 0.2)', }, }, }, MuiChip: { defaultProps: { variant: 'filled' }, styleOverrides: { root: { borderRadius: 24, fontWeight: 600 }, }, }, }; const initechComponents: TenantComponents = { MuiButton: { defaultProps: { variant: 'outlined' }, styleOverrides: { root: { borderRadius: 0, textTransform: 'uppercase', letterSpacing: 1 }, }, }, MuiCard: { defaultProps: { elevation: 2 }, styleOverrides: { root: { borderRadius: 0 }, }, }, }; // Compose with base theme const baseOptions: ThemeOptions = { typography: { button: { textTransform: 'none' } }, }; export const acmeTheme = createTheme( deepmerge(baseOptions, { palette: { primary: { main: '#ff5722' } }, components: acmeComponents, }), ); export const initechTheme = createTheme( deepmerge(baseOptions, { palette: { primary: { main: '#4caf50' } }, components: initechComponents, }), ); ``` --- ## TypeScript for Multi-Theme ### Generic Theme Factory Function ```tsx import { createTheme, type Theme, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; // Shared base type for all tenant configs interface TenantThemeConfig { tenantId: string; palette: { primary: string; secondary: string; mode?: 'light' | 'dark'; }; typography?: { fontFamily?: string; headingFontFamily?: string; }; shape?: { borderRadius?: number; }; components?: ThemeOptions['components']; } const baseThemeOptions: ThemeOptions = { typography: { fontFamily: '"Inter", sans-serif', button: { textTransform: 'none' }, }, shape: { borderRadius: 8 }, components: { MuiButton: { defaultProps: { disableElevation: true } }, MuiTextField: { defaultProps: { variant: 'outlined', size: 'small' } }, }, }; export function createThemeFromConfig(config: TenantThemeConfig): Theme { const overrides: ThemeOptions = { palette: { mode: config.palette.mode ?? 'light', primary: { main: config.palette.primary }, secondary: { main: config.palette.secondary }, }, ...(config.typography && { typography: { fontFamily: config.typography.fontFamily, ...(config.typography.headingFontFamily && { h1: { fontFamily: config.typography.headingFontFamily }, h2: { fontFamily: config.typography.headingFontFamily }, h3: { fontFamily: config.typography.headingFontFamily }, h4: { fontFamily: config.typography.headingFontFamily }, }), }, }), ...(config.shape && { shape: config.shape }), ...(config.components && { components: config.components }), }; return createTheme(deepmerge(baseThemeOptions, overrides)); } // Type-safe tenant registry const configs: Record = { acme: { tenantId: 'acme', palette: { primary: '#ff5722', secondary: '#ff9800' }, typography: { fontFamily: '"Poppins", sans-serif' }, }, globex: { tenantId: 'globex', palette: { primary: '#00bcd4', secondary: '#7c4dff', mode: 'dark' }, shape: { borderRadius: 4 }, }, }; export function getThemeForTenant(tenantId: string): Theme { const config = configs[tenantId]; if (!config) throw new Error(`Unknown tenant: ${tenantId}`); return createThemeFromConfig(config); } ``` ### Augmenting the Theme Type for Custom Tokens ```tsx import { createTheme } from '@mui/material/styles'; // Declare custom tokens on the theme declare module '@mui/material/styles' { interface Theme { brand: { logoUrl: string; logoHeight: number; appName: string; }; } interface ThemeOptions { brand?: { logoUrl?: string; logoHeight?: number; appName?: string; }; } } const acmeTheme = createTheme({ palette: { primary: { main: '#ff5722' } }, brand: { logoUrl: '/brands/acme/logo.svg', logoHeight: 40, appName: 'Acme Portal', }, }); // Now type-safe in any component: // const theme = useTheme(); // theme.brand.logoUrl <-- autocompletes ``` --- ## Performance ### Memoize Theme Creation `createTheme` is expensive. Never call it inside a render function without memoization. ```tsx import { useMemo } from 'react'; import { createTheme, ThemeProvider, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; const baseOptions: ThemeOptions = { /* ... */ }; function TenantApp({ overrides, children }: { overrides: ThemeOptions; children: React.ReactNode }) { // GOOD — only re-creates when overrides reference changes const theme = useMemo( () => createTheme(deepmerge(baseOptions, overrides)), [overrides], ); return {children}; } ``` ### Avoid Re-Creating Overrides Object ```tsx // BAD — new object on every render, theme re-created every time function App({ primaryColor }: { primaryColor: string }) { return ( {/* ... */} ); } // GOOD — stable reference function App({ primaryColor }: { primaryColor: string }) { const overrides = useMemo( () => ({ palette: { primary: { main: primaryColor } } }), [primaryColor], ); return {/* ... */}; } ``` ### Cache Themes for Known Tenants See the Theme Registry Pattern section above. The `Map`-based cache prevents re-creating the same theme on navigation or re-mount. --- ## Complete White-Label Architecture Full example tying together theme factory, tenant loader, brand context, and provider. ### File: `lib/brand/types.ts` ```tsx export interface TenantBrandConfig { tenantId: string; name: string; domain: string; logoUrl: string; logoHeight: number; faviconUrl: string; palette: { mode: 'light' | 'dark'; primary: string; secondary: string; error?: string; warning?: string; success?: string; }; typography: { fontFamily: string; headingFontFamily?: string; googleFontsUrl?: string; // link to Google Fonts CSS }; shape: { borderRadius: number; }; components?: { buttonVariant?: 'contained' | 'outlined' | 'text'; buttonBorderRadius?: number; cardElevation?: number; cardBorderRadius?: number; }; } ``` ### File: `lib/brand/theme-factory.ts` ```tsx import { createTheme, type Theme, type ThemeOptions } from '@mui/material/styles'; import deepmerge from '@mui/utils/deepmerge'; import type { TenantBrandConfig } from './types'; const BASE_THEME: ThemeOptions = { typography: { button: { textTransform: 'none', fontWeight: 600 }, h1: { fontWeight: 800 }, h2: { fontWeight: 700 }, }, components: { MuiButton: { defaultProps: { disableElevation: true } }, MuiTextField: { defaultProps: { variant: 'outlined', size: 'small' } }, MuiCssBaseline: { styleOverrides: { body: { scrollBehavior: 'smooth' }, }, }, }, }; const cache = new Map(); export function createBrandTheme(config: TenantBrandConfig): Theme { const cached = cache.get(config.tenantId); if (cached) return cached; const overrides: ThemeOptions = { palette: { mode: config.palette.mode, primary: { main: config.palette.primary }, secondary: { main: config.palette.secondary }, ...(config.palette.error && { error: { main: config.palette.error } }), ...(config.palette.warning && { warning: { main: config.palette.warning } }), ...(config.palette.success && { success: { main: config.palette.success } }), }, typography: { fontFamily: config.typography.fontFamily, ...(config.typography.headingFontFamily && { h1: { fontFamily: config.typography.headingFontFamily }, h2: { fontFamily: config.typography.headingFontFamily }, h3: { fontFamily: config.typography.headingFontFamily }, h4: { fontFamily: config.typography.headingFontFamily }, }), }, shape: { borderRadius: config.shape.borderRadius }, components: { ...(config.components?.buttonVariant && { MuiButton: { defaultProps: { variant: config.components.buttonVariant }, ...(config.components.buttonBorderRadius != null && { styleOverrides: { root: { borderRadius: config.components.buttonBorderRadius }, }, }), }, }), ...(config.components?.cardElevation != null && { MuiCard: { defaultProps: { elevation: config.components.cardElevation }, ...(config.components.cardBorderRadius != null && { styleOverrides: { root: { borderRadius: config.components.cardBorderRadius }, }, }), }, }), }, }; const theme = createTheme(deepmerge(BASE_THEME, overrides)); cache.set(config.tenantId, theme); return theme; } export function invalidateBrandCache(tenantId?: string): void { if (tenantId) { cache.delete(tenantId); } else { cache.clear(); } } ``` ### File: `lib/brand/BrandProvider.tsx` ```tsx import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import CircularProgress from '@mui/material/CircularProgress'; import Box from '@mui/material/Box'; import { createContext, useContext, useState, useEffect, useMemo, type ReactNode, } from 'react'; import type { TenantBrandConfig } from './types'; import { createBrandTheme } from './theme-factory'; // --- Brand Context --- const BrandContext = createContext(null); export function useBrand(): TenantBrandConfig { const ctx = useContext(BrandContext); if (!ctx) throw new Error('useBrand() must be used within '); return ctx; } // --- Tenant Loader --- async function loadTenantConfig(tenantId: string): Promise { const res = await fetch(`/api/tenants/${tenantId}/brand`); if (!res.ok) throw new Error(`Brand config fetch failed (${res.status})`); return res.json(); } // --- Font Loader --- function loadGoogleFonts(url: string): void { if (document.querySelector(`link[href="${url}"]`)) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; document.head.appendChild(link); } // --- Favicon Setter --- function setFavicon(url: string): void { let link = document.querySelector('link[rel="icon"]'); if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } link.href = url; } // --- Provider Component --- interface BrandProviderProps { tenantId: string; fallbackConfig?: TenantBrandConfig; // optional static fallback children: ReactNode; } export function BrandProvider({ tenantId, fallbackConfig, children }: BrandProviderProps) { const [config, setConfig] = useState(fallbackConfig ?? null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; loadTenantConfig(tenantId) .then((cfg) => { if (cancelled) return; setConfig(cfg); // Side effects: load fonts, set favicon, set document title if (cfg.typography.googleFontsUrl) loadGoogleFonts(cfg.typography.googleFontsUrl); if (cfg.faviconUrl) setFavicon(cfg.faviconUrl); document.title = cfg.name; }) .catch((err) => { if (!cancelled) setError(err.message); }); return () => { cancelled = true; }; }, [tenantId]); const theme = useMemo(() => (config ? createBrandTheme(config) : null), [config]); if (error && !config) { return ( Failed to load brand: {error} ); } if (!theme || !config) { return ( ); } return ( {children} ); } ``` ### File: `App.tsx` (entry point) ```tsx import { BrandProvider } from './lib/brand/BrandProvider'; import { BrandedHeader } from './components/BrandedHeader'; import { Dashboard } from './pages/Dashboard'; // Tenant ID typically comes from subdomain, URL param, or env variable function getTenantId(): string { const subdomain = window.location.hostname.split('.')[0]; return subdomain === 'localhost' ? 'acme' : subdomain; } export default function App() { const tenantId = getTenantId(); return ( ); } ``` ### File: `components/BrandedHeader.tsx` ```tsx import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { useBrand } from '../lib/brand/BrandProvider'; export function BrandedHeader() { const brand = useBrand(); return ( {brand.name} ); } ```