Articles
An Architecture Pattern for Multiple Layouts and Themes
How can a frontend support 100+ themes and multiple layouts without letting custom logic seep into shared components? This article breaks down the core / view / theme leaf layers, typed configuration, family completeness tests, and on-demand theme CSS loading, along with the tradeoffs at each layer.
· Updated · 9 min read
I was once responsible for an architectural overhaul of a large Nx / Angular frontend platform. It needed to support more than 100 themes and several layouts. Team members often completed multi-layout, multi-theme subtasks in ways that suited their own development habits. Over time, that accumulated into technical debt: the same requirement had multiple implementations at once.
This article explains the core design decisions: how to separate the layers, keep more than 100 themes complete, and make sure the browser downloads only the CSS it needs. The original production code can’t be made public, so I reimplemented the architecture with original code and three fictional themes in frontend-engineering-case-studies, along with a live demo you can try yourself. All code in the article comes from this public version.
Here’s the takeaway: Separate how things look from what they do, then let the compiler and tests keep more than 100 themes consistent.
The Problem Is More Than Changing Colors#
The most common starting point for supporting multiple themes is to add conditions inside a shared component. For example, the A theme needs one more button, while the B theme needs to link to a different page during a campaign:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
With only three themes, this is quick to write. Once there are 100 themes, every shared component carries a pile of branches that apply only to certain themes. The result:
- A change requested for Theme A might quietly break Theme B, with nobody responsible for checking B.
- The same difference gets implemented separately in different components, and each implementation works differently.
- When a new theme is added, no one can be sure that every required spot has been updated.
So the problem isn’t just changing colors. It’s allowing each theme to differ in appearance and product behavior while keeping the whole theme family complete and predictable.
First, Separate Layouts from Themes#
A house is an easy way to think about it:
- Structure and utilities: beams, plumbing, and wiring. They’re the same in every unit and shouldn’t change to suit a resident’s preferences.
- Layout: the floor plan. Two bedrooms or three, where the living room goes. It determines “how things are arranged.”
- Theme: the decor. Wall colors, materials, corner radius. It determines “what things look like.”
In a frontend, these three concerns belong to different layers. Mix them together, and you end up changing the data flow just to change a color.
Four Layers#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
From the bottom up:
core: Only Renders#
core components accept data that’s ready to render and emit only the user’s intent. They don’t know which theme is active, handle routing, deal with API response formats, or reference any brand assets.
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
core is shared by every theme, so its reach is the broadest and its rules are the strictest: only the necessary inputs, and only intent as output.
view: Coordinates#
view sits in the middle. It gets data from the data service and shapes it for core; when core emits an intent, view hands it to the actions facade.
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
Where data comes from (data service) and what happens when something is clicked (actions facade) each have a clear boundary, so that logic doesn’t get scattered across UI components.
Layouts fit in this layer. Different floor plans can be different shared views. They use the same data service and facade, but arrange core components differently. The public demo has only one shared view for simplicity; the production overhaul introduced 26.
theme leaf: One Leaf per Theme#
Each theme is a thin component with a single job: choose a complete appearance config and pass it to the shared view.
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
There are no business conditions inside it. In production, a representative, fully implemented theme leaf was 23 lines after the refactor; the same path in the old implementation was several hundred lines.
selector: Explicitly Lists Family Members#
The selector renders the matching leaf based on the runtime theme id:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
It looks ordinary. The key is that it’s explicit: you can see at a glance which themes exist and which leaf each one maps to, and tests can check them one by one.
Let the Compiler Catch Missing Themes#
With many themes, the biggest risk is omission: adding a theme but forgetting its config, or changing the config shape while some existing themes fall behind.
Start with a single source of truth for the theme list, then use TypeScript’s satisfies to require a complete config for every theme:
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
Add a theme to DEMO_THEMES but fail to add it to THEME_APPEARANCES, and compilation fails. The same goes for misspelled fields or an invalid CSS path format.
Using satisfies instead of a direct type annotation lets TypeScript check the shape while retaining each value’s specific type. When you access THEME_APPEARANCES.aurora later, its type won’t be widened to a generic ThemeAppearance.
Use Tests to Keep the Whole Family Complete#
The compiler can validate the config, but it can’t tell whether a theme actually renders. That’s what family completeness tests are for: they enumerate every declared theme one by one:
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
There’s a corresponding test for the config too: each theme config’s id field must match its key in the config table, and its CSS path must follow the family’s rules.
Together, these checks turn the manual review task of making sure no sibling theme was missed into an error that CI can catch. No one can review more than 100 themes by eye.
Download Only the Theme CSS You’re Using#
Layering solves a maintenance problem, but multiple themes also come with a cost that directly affects users: CSS.
Originally, all 136 theme stylesheets were bundled together. No matter which theme they used, users had to download and parse them all. With on-demand loading, the resources for a single theme dropped from about 2.4 MB to 17 KB, a reduction of about 99%.
The mechanism is simple: each theme config declares its own stylesheetHref, and a single loader manages the page’s one theme <link>:
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
The platform calls it when the theme changes:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
“Single” is the key. If each part of the app inserts its own <link>, repeated switching can leave old theme styles behind or cause two CSS files to override each other. A test verifies that after several switches, the page still has only one managed stylesheet.
Theme CSS defines design tokens (CSS variables), and core styles read only those variables:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
So changing themes just swaps one set of variables for another; core styles don’t need to change at all. Light and dark modes are another set of variables in the same file.
Tradeoffs#
This design isn’t free:
- More files. One leaf per theme and one view per layout mean more small files. In return, each file’s responsibility is immediately clear, and completeness can be checked.
- Config shouldn’t become a programming language. Config is good for describing appearance and bounded differences. Once you start putting conditions and workflows in config, you’ve just hidden the original if/else somewhere else.
- core needs stronger contract tests. Every theme shares it, so a single change affects more than 100 themes.
- Runtime CSS switching needs a single owner. Uncoordinated stylesheet requests can cause flickering or leave styles behind, which is why there must be only one loader.
- Caching still matters. Even if only one theme is loaded, stable filenames and cache headers still determine how fast the site is on return visits.
When This Is Worth Doing#
If your product has only light and dark modes, CSS variables are enough. You don’t need this whole setup.
It’s worth considering when you start seeing these situations:
- You have more than ten themes or customer versions, and the number is growing.
- Differences between themes go beyond color to include layout, feature flags, or copy.
- A change for one theme often affects another by accident.
- When adding a theme, no one can be sure what needs to be updated.
Evidence Boundaries#
The scope of the production numbers in this article needs to be clear:
- 23 lines refers to one representative, fully implemented theme leaf after the refactor. It doesn’t mean every leaf or file is 23 lines.
- That migration introduced 70 modules using the new architecture (24 facades, 10 data services, 10 theme configs, and 26 shared views). This describes the scope of the overhaul, not performance gains or code reduction.
- 2.4 MB → 17 KB is a measurement from production. The three small CSS files in the public demo won’t reproduce that figure.
- In the same quality initiative, Lighthouse Performance on a key measured page improved from 50+ to 90+, but that was the combined result of several improvements, not on-demand CSS loading alone.
The public repo demonstrates the architecture itself: typed contracts, runtime switching, family completeness tests, on-demand CSS, and the CI that runs those checks. The full claims-to-evidence table is in the repo’s evidence boundary audit.
To see it in action, open the live demo to switch between three themes, languages, and light and dark modes, then compare it with the source code on GitHub.