Sharing a Component Library Across Tenants with Design Tokens
This guide, part of Multi-Tenant Architecture Patterns, shows how one component library can render many tenants’ sites with their own look and optional features, without code branches per tenant. The approach combines design tokens selected by the resolved tenant, feature flags from the tenant registry, and content-driven layout through the shared block model.
Multi-tenant frontends drift toward special cases. A tenant wants a different button shape, another a different header, a third a feature nobody else has. Each request is small, and the easy answer is an if on the tenant id. After two years, the codebase has hundreds of such branches, every change must be tested against every tenant, and nobody dares to refactor. Design tokens and flags move variation from code into data, where it can grow without making the code harder to change.
The Problem
An agency’s multi-tenant storefront served 25 retailers. Over three years, the component code accumulated 340 conditions on tenant ids, mostly for colours, fonts and small layout differences, plus a few for features. A redesign of the product card took six weeks, because each of its 19 tenant conditions had to be understood and preserved, and two tenants’ cards still broke in production because their conditions interacted in ways nobody had tested. Onboarding a new retailer required code changes and a release just to set its colours.
How Tokens and Flags Replace Branches
Design tokens are named values for visual decisions: color.brand.primary, radius.button, font.heading.family, space.section. Components use tokens exclusively, never raw values. Each tenant has a token set, a small JSON file, compiled into CSS custom properties scoped to the tenant. Changing a tenant’s colours means editing its token file, not the components, and the change can be reviewed by a designer without reading any code.
Semantic tokens sit between tenant values and components: color.action.background points to color.brand.primary for most tenants but can point elsewhere for one. Components use semantic tokens, which gives tenants flexibility without new component variants.
Feature flags in the tenant registry switch optional features on or off. Components check the flag, not the tenant id, so enabling a feature for another tenant is a registry change.
Content-driven layout keeps arrangement in the CMS. Tenants arrange the shared blocks differently, as described in modeling page-builder blocks, rather than asking for different templates.
Implementation
Token files per tenant override a shared base. A build step compiles them into one CSS file per tenant with custom properties scoped by a data attribute. This is tokens/acme.json, which overrides only what differs from tokens/base.json:
{
"color": {
"brand": { "primary": { "value": "#0b5fff" }, "accent": { "value": "#ff7a00" } }
},
"font": { "heading": { "family": { "value": "'Söhne', system-ui, sans-serif" } } },
"radius": { "button": { "value": "999px" } }
}
// scripts/build-tokens.mjs
import StyleDictionary from "style-dictionary";
import { readdirSync } from "node:fs";
const tenants = readdirSync("tokens").filter((f) => f !== "base.json").map((f) => f.replace(".json", ""));
for (const tenant of tenants) {
const sd = new StyleDictionary({
source: ["tokens/base.json", "tokens/semantic.json", `tokens/${tenant}.json`],
platforms: {
css: {
transformGroup: "css",
buildPath: "public/tokens/",
files: [{
destination: `${tenant}.css`,
format: "css/variables",
options: { selector: `[data-tenant="${tenant}"]`, outputReferences: true },
}],
},
},
});
await sd.buildAllPlatforms();
}
The tenant layout loads the tenant’s stylesheet and sets the data attribute, using the tenant resolved at the edge as described in resolving tenants. Components use only semantic custom properties.
// app/_tenants/[tenant]/layout.tsx
import { getTenant } from "@/lib/tenant-registry";
import { notFound } from "next/navigation";
export default async function TenantLayout({ children, params }: { children: React.ReactNode; params: Promise<{ tenant: string }> }) {
const tenant = await getTenant((await params).tenant);
if (!tenant) notFound();
return (
<html lang={tenant.defaultLocale} data-tenant={tenant.id}>
<head><link rel="stylesheet" href={`/tokens/${tenant.id}.css`} /></head>
<body>{children}</body>
</html>
);
}
// components/button.module.css uses only semantic tokens:
// .button { background: var(--color-action-background); border-radius: var(--radius-button); }
Feature flags come from the same registry entry and are passed to components through context, so a component asks useFeature("reviews") rather than checking the tenant.
Keeping tokens safe
Token files are data that designers or account managers may edit, so validate them in CI: every required semantic token resolves, colour pairs used for text and background meet contrast requirements, and font families reference fonts that are actually loaded. A contrast check across all tenants catches the common mistake of a light brand colour used for button text before it reaches a live site.
Configuration Reference
| Concern | Recommendation | Why |
|---|---|---|
| Token layers | base, semantic, tenant overrides | Tenants change only what differs. |
| Component styling | semantic tokens only | Components stay tenant-agnostic. |
| Token output | one CSS file per tenant, scoped by data attribute | Small, cacheable, no runtime cost. |
| Optional features | registry flags checked by feature name | Enabling a feature needs no release. |
| Validation | resolution, contrast and font checks in CI | Broken themes never deploy. |
| Visual tests | per tenant for key components | Changes are checked in every theme. |
Gotchas & Edge Cases
- Tokens that are really variants. When a tenant needs a structurally different component, such as a header with a second row, a token cannot express it. Add a variant to the shared component, selected by a token or flag, rather than a tenant branch.
- Too many tokens. Tokenizing every value makes token files as complex as the CSS they replace. Start with colours, typography, radii and spacing scales, and add tokens only when a tenant needs one.
- Font loading. Tenant fonts must be loaded per tenant too, or the token names a font the browser does not have. Load font files in the tenant stylesheet.
- Dark mode. If tenants support dark mode, each needs a dark token set, and contrast validation must run for both.
Worked Example
The agency extracted all colour, typography, radius and spacing values into tokens, created token files for the 25 retailers and replaced 290 of the 340 tenant conditions with semantic tokens. Of the remaining 50, 38 became feature flags in the registry and 12 became variants of shared components. The next product card redesign took eight days and was verified with visual tests in all 25 themes. Onboarding a new retailer no longer required a code release: a token file and a registry entry were enough. Account managers could adjust a retailer’s colours themselves through a validated form, and the CI contrast check rejected two proposed palettes in the first month before they reached a live site.
Visual Testing Across Tenants
A shared library is only safe to change when every tenant’s rendering is checked. Render key components and page templates in Storybook or a similar tool once per tenant theme, using the same fixtures, and compare screenshots against approved baselines on every pull request. With 25 tenants and 30 key stories, that is 750 screenshots, which is manageable with parallel runs and a service that highlights differences. Review changes per component across all tenants at once: a new padding value that looks right in the default theme may break a tenant with a larger heading font. Add a contrast check to the same run, since visual tests do not measure accessibility. The result is that designers and developers can change shared components with confidence, which is the whole point of sharing them.
Rollout Checklist
- Define base and semantic tokens, and move component styles to semantic tokens only.
- Create a token file per tenant with only its overrides.
- Compile per-tenant CSS scoped by a data attribute, loaded by the resolved tenant.
- Replace tenant checks with registry feature flags and shared variants.
- Validate tokens in CI for resolution, contrast and fonts.
- Run visual tests per tenant for key components.
Frequently Asked Questions
Can tenants edit their own tokens?
Yes, through a controlled interface or a CMS content type that generates the token file, provided CI validation runs before publishing. Contrast checks are essential if non-designers edit colours.
Does per-tenant CSS hurt performance?
No. Each tenant loads one small stylesheet of custom properties, cached like any static asset. It is often smaller than the conditional CSS it replaces.
How do we handle tenant-specific logos and images?
As assets referenced from the tenant registry or the tenant’s CMS space, not as tokens. Tokens are for values; assets belong in content.
How do designers work with tenant themes?
In the design tool, with variables or styles that mirror the semantic tokens, one mode per tenant. Exporting those variables into the token files keeps design and code in sync, and a designer can preview a component in every tenant theme before any code changes.
What if a tenant needs a completely custom page?
Build it from shared blocks where possible. If it truly needs a unique component, add that component to the shared library behind a flag, so the code remains shared even if only one tenant uses it today.