Skip to content
fullstackhero

Concept

Per-tenant theming

How tenant branding works in the kit — the TenantTheme aggregate in the Multitenancy module, the admin branding editor, and the dashboard's per-user appearance system.

views 0 Last updated

Theming in the kit has two layers that are easy to conflate, so let’s name them up front:

  1. Per-tenant branding — the Multitenancy module owns a TenantTheme aggregate (colour palettes, brand assets, typography, layout) with permission-gated read/update/reset endpoints and an editor in the admin console. Themes are data, not deploys — editing one never requires a rebuild.
  2. Per-user appearance — the dashboard ships a rich appearance system (light/dark/system mode, accent colour, font, density, reduced motion) that each user controls from Settings → Appearance and that persists in localStorage.

The TenantTheme store + editor are fully wired end-to-end on the backend and admin side. The dashboard does not yet read TenantTheme to repaint its chrome per tenant — its visual identity today comes from the per-user appearance system. If you want full white-label rendering, the wiring point is small and described below.

The TenantTheme model

The aggregate lives at src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs; the API exchanges a structured TenantThemeDto (light palette, dark palette, brand assets, typography, layout).

Palette

Light mode + dark mode values for nine roles:

RoleDefault lightDefault darkUsed for
Primary#2563EB#38BDF8Buttons, links, accents
Secondary#0F172A#94A3B8Secondary buttons, badges
Tertiary#6366F1#818CF8Tertiary accents, dividers
Background#F8FAFC#0B1220App background
Surface#FFFFFF#111827Cards, sheets, dialogs
Error#DC2626#F87171Destructive actions, error banners
Warning#F59E0B#FBBF24Cautionary states
Success#16A34A#22C55ESuccess states
Info#0284C7#38BDF8Informational states

Brand assets

  • LogoUrl — light-mode logo.
  • LogoDarkUrl — dark-mode logo.
  • FaviconUrl — browser tab icon.

Uploads go through the kit’s storage service — the update endpoint accepts inline image data, stores it, and replaces (and cleans up) the previous asset.

Typography

  • FontFamily — body font (default Inter, sans-serif).
  • HeadingFontFamily — display font for headings (default Inter, sans-serif).
  • FontSizeBase — body font size in px (default 14).
  • LineHeightBase — body line height (default 1.5).

Layout

  • BorderRadius — radius for cards, buttons, inputs (CSS string, default 4px).
  • DefaultElevation — shadow strength (default 1).

Default theme

IsDefault marks one theme as the platform default. Only the root tenant can set it; any tenant without its own saved theme resolves to the kit’s built-in defaults.

The endpoints

All three are current-tenant-scoped — they act on whichever tenant the request’s tenant header resolves to (there’s no {id} in the route) — and permission-gated:

EndpointPermissionDoes
GET /api/v1/tenants/themeTenants.ViewThemeReturns the tenant’s theme, or the defaults if none is saved
PUT /api/v1/tenants/themeTenants.UpdateThemeUpserts the theme (validated — colour formats, font sizes)
POST /api/v1/tenants/theme/resetTenants.UpdateThemeResets every field to the kit defaults

Reads are cached via HybridCache (1 hour distributed / 2 minutes local) and invalidated on every update or reset, so theme fetches don’t hit the database per request.

Editing themes in the admin console

The branding card on the admin console’s tenant detail page is the operator-facing editor. It scopes each call to the target tenant by overriding the tenant header — only root operators get past that override — and covers the light + dark palettes plus brand assets. Typography and layout exist on the DTO but are intentionally left out of the v1 editor; wire them in if your product needs them.

Save persists via PUT /api/v1/tenants/theme; Reset calls POST /api/v1/tenants/theme/reset. Both invalidate the server-side cache immediately.

The dashboard’s appearance system (per-user)

What a dashboard user can customise today is personal, not tenant-wide — Settings → Appearance:

  • Mode — light / dark / system, applied with a View Transitions crossfade.
  • Accent — preset accent colours or a fully custom hue, applied as the eleven --brand-* CSS custom-property stops.
  • Font, density, reduced motion — per-user toggles.

Everything persists in localStorage (fsh.theme, accent/font/density keys) and applies before the app paints, so there’s no flash of the wrong mode. The dashboard’s neutral greys are deliberately untinted (zero chroma) so whatever accent the user — or eventually the tenant — picks does the branding work without fighting a tinted chassis.

Rendering TenantTheme in your own frontend

The server side is done; consuming it is the part you own. The shape:

  1. After login, call GET /api/v1/tenants/theme (the signed-in user needs Tenants.ViewTheme).
  2. Map the DTO onto CSS custom properties on document.documentElement — palette roles to colour variables, typography to --font-*, BorderRadius to your radius token.
  3. Swap the logo / favicon from BrandAssets.
  4. Cache the last-applied theme in localStorage and re-apply it synchronously in index.html before React mounts, so returning users see their brand on first paint instead of a default flash.

Because every kit component already resolves colours through CSS variables, step 2 is a mapping exercise, not a redesign.

What theming doesn’t do

  • It’s not a full white-label. The component library (sidebar shape, navigation pattern, dialog conventions) stays the same. Theming changes colours, fonts, and assets; it doesn’t change layout.
  • It doesn’t theme the admin console. Admin is for your operators; it stays in the kit’s own palette so operators have a consistent surface across tenants.
  • It doesn’t ship a theme marketplace. Tenants get one theme each; there’s no “browse themes” gallery. If you want that, add a ThemeTemplate aggregate with a one-click apply.