It is never just the logo
Every multi-tenant product I've worked on has the same conversation about six weeks in. A client wants their logo in the header. Fine, says everyone, that's a config field.
It is never just the logo. It's the logo, then the primary colour, then the primary colour in the email templates, then their font, then their domain, then a request to move the logo because at their aspect ratio it looks squashed. Each individual ask is twenty minutes. Together they're an architecture, and if you don't decide that up front you get a theme.ts with a switch statement in it that somebody will still be maintaining in 2029.
The constraint I set on the coworking platform was that onboarding an operator could not involve a deploy. Not "a fast deploy". None. If the theming story requires a build, it isn't a config field, it's a fork with extra steps.
Variables, rendered per request
The whole thing rests on CSS custom properties, which is not clever, but the placement is what matters.
Tailwind's tokens map to variables rather than fixed values, so the utility classes stay the same across every tenant and only the variable definitions change. Then the tenant's values get inlined into the document at request time in the root layout:
const theme = await getTenantTheme(tenantId)
return (
<html lang={theme.locale}>
<head>
<style
dangerouslySetInnerHTML={{
__html: `:root{${themeToCssVars(theme)}}`,
}}
/>
</head>
<body>{children}</body>
</html>
)
Inline in the head, not a stylesheet link, and not applied by client-side JavaScript. That's the part people get wrong, including me, on the first attempt.
If the theme is applied after hydration, every visitor sees a flash of your default brand before the correct one arrives. On a fast connection it's a blink. On a mid-range Android phone on a hotel network it's most of a second, and the one person guaranteed to notice is the operator you just onboarded, on the demo call, in front of their own team. It's a rendering bug that only ever gets found by the people you least want finding it.
Fetching the theme puts a database read on the critical path for every request, so it's cached aggressively with explicit invalidation when the tenant record changes. That cache and the host-to-tenant lookup are the two pieces of the system I've spent the most time thinking about since, which I've written about separately in the piece on where tenancy lives.
Brand colours are not design tokens
Here's the one I didn't see coming.
An operator sends you their brand colour. It's a mid-yellow, because their brand book says so, and your primary button renders white text on it. The result is unreadable, fails WCAG comfortably, and it isn't the client's fault or your designer's fault. It's that a brand palette and a UI palette are different things solving different problems, and treating the first as the second breaks the moment someone's brand is light.
So the tenant record stores the brand colour, but the tokens the UI actually uses are derived from it at save time. We compute the foreground for each surface by contrast ratio rather than assuming white, generate the hover and active states by adjusting lightness in a perceptual space instead of plain HSL, and run a contrast check as part of saving the theme. If a combination fails, the admin sees it before it ships, with the nearest passing alternative offered.
Two colours out of the first dozen operators failed that check. Both times, showing them the adjusted version alongside their original ended the discussion in a minute, because the adjusted one obviously looked better.
Fonts, assets, and email
Fonts are where the no-build rule bites hardest. next/font is excellent and it is build-time by design, which means a tenant-chosen font can't use it. We settled on a curated set of families bundled at build with a variable per tenant selecting between them, and self-hosted webfont files for the two enterprise clients who genuinely needed their licensed typeface. It's a compromise. An operator can't upload a font file and see it live, and so far nobody has asked to.
Logos taught us to constrain by box rather than by dimensions. The first version specified a height and let width flow, which is fine until you get a wordmark with an eleven-to-one aspect ratio next to a circular emblem. Now every logo renders inside a fixed box with object-fit containment, uploads are checked for transparency and minimum resolution, and there are separate slots for the horizontal wordmark and the square mark because those are genuinely different assets and asking for one file to serve both never works.
Email is a separate rendering pipeline with separate rules and it will not use your CSS variables. Mail clients want inlined styles, several of them apply their own dark mode inversion whether you like it or not, and a logo with a transparent background can end up invisible. Same theme record, different renderer, and it needs testing on its own.
The thing I still haven't solved nicely
Favicons and the PWA manifest.
Both are static file references that browsers cache with unusual enthusiasm, both need to vary per tenant, and the manifest needs icons at half a dozen sizes generated from whatever the operator uploaded. We generate them on upload and serve them from tenant-scoped routes, which works, but the caching behaviour differs enough between browsers that I don't fully trust it. Every so often somebody reports the wrong icon in a bookmark and I have no reliable way to reproduce it.
If you've solved that one properly I'd genuinely like to hear about it.