Systems Thinking in Website Builder and Multi-Site Architecture
The nuances of multi-tenant system design that rarely get discussed openly — yet without them, a Website Builder and its multisite ecosystem inevitably fall apart under real-world load.
Building a Website Builder sounds like a well-understood problem. There are reference products everywhere. What is not well-documented is the category of mistakes that only emerge once you have dozens of tenants with conflicting requirements, a shared rendering pipeline, and engineers who joined after the original architectural decisions were made.
This article is about the systems thinking layer — the mental models and structural decisions that determine whether a multi-tenant frontend scales or becomes a permanent source of incidents.
The Fundamental Tension: Shared Infrastructure vs. Tenant Autonomy
Every multi-tenant system lives in the tension between two opposing forces. Shared infrastructure is cheaper and easier to operate. Tenant autonomy is what users pay for. The job of the frontend architect is to find the boundary where these forces meet — and to make that boundary explicit rather than accidental.
The worst outcome is an implicit boundary: code that happens to be shared, configuration that happens to be per-tenant, and a team that has to check three different places to understand which applies. This is where multi-tenant systems rot from the inside.
Configuration Layering
We model configuration as an explicit three-layer stack. Each layer can override the one above it, and the resolution order is always predictable:
// config/resolve-tenant-config.ts
type GlobalConfig = {
maxPages: number;
allowCustomDomain: boolean;
supportedLocales: string[];
};
type PlanOverrides = Partial<GlobalConfig>;
type TenantOverrides = Partial<GlobalConfig> & {
brandColor?: string;
logoUrl?: string;
};
export function resolveTenantConfig(
global: GlobalConfig,
plan: PlanOverrides,
tenant: TenantOverrides,
): GlobalConfig & TenantOverrides {
return { ...global, ...plan, ...tenant };
}
This sounds almost too simple — and that is the point. The moment this logic becomes conditional, context-dependent, or feature-flagged, you have introduced hidden coupling. The resolution function must remain a pure merge.
CSS Scoping: The Problem Nobody Talks About
Multi-tenant sites share a rendering engine but need completely independent visual identities. CSS leaking between tenants is one of the most common and hardest-to-debug problems in Website Builder architectures.
We tried three approaches before settling on the right one:
Approach 1: CSS Modules per component (abandoned)
Works fine for component-level isolation but does nothing for tenant-level theme variables. You still need a global token layer, and that layer bleeds between tenants if you are not careful.
Approach 2: Inline styles for theme tokens (abandoned)
Effective for isolation but catastrophic for performance — kills browser style sharing, bloats the DOM, and breaks pseudo-elements entirely.
Approach 3: CSS custom properties scoped to a tenant root (current)
/* Generated per-tenant at build time or runtime */
[data-tenant="acme"] {
--color-brand: #e63946;
--color-surface: #f1faee;
--font-heading: 'Playfair Display', serif;
}
[data-tenant="globex"] {
--color-brand: #2b2d42;
--color-surface: #edf2f4;
--font-heading: 'Inter', system-ui, sans-serif;
}
Components reference only --color-brand, --color-surface, and so on. The tenant root attribute is set server-side and never touches React state. Switching themes is a DOM attribute change — zero re-renders.
<style> block in the document <head> if tenants are dynamic. Never load them as an external stylesheet — that adds a render-blocking request.
Routing in a Multisite Context
Multisite routing has two distinct patterns, and choosing the wrong one creates years of pain:
- Path-based routing —
/tenant-slug/page-slug— simpler to operate but leaks the tenant identifier into every URL, which breaks custom domain expectations - Host-based routing —
tenant.example.comorcustomdomain.com— correct UX but requires infrastructure work (wildcard DNS, TLS provisioning) and makes local development non-trivial
We use host-based routing in production and path-based routing in development, with an environment variable that switches the resolution strategy. The routing layer is the only place that knows which strategy is active; the rest of the application always receives a resolved tenantId.
// middleware.ts (Next.js)
export function middleware(request: NextRequest) {
const tenantId = process.env.NODE_ENV === 'development'
? resolveFromPath(request.nextUrl.pathname)
: resolveFromHost(request.headers.get('host') ?? '');
if (!tenantId) return NextResponse.next();
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenantId);
return response;
}
The Shared Rendering Pipeline
The riskiest part of any Website Builder is the page renderer — the component that takes a tenant's page definition and turns it into HTML. If this component has any global side effects (setting document title, injecting scripts, modifying body classes), those effects will interfere across tenants in any environment where multiple tenants render in the same process.
Rules we enforce for every renderer component:
- No direct
documentaccess — usenext/heador the equivalent abstraction - No global event listeners — attach to the tenant root element, not
window - No module-level mutable state — all state lives in React context scoped below the tenant root
- Renderer components are tested in isolation with a mock tenant config — never tested against a real database in unit tests
Systems Thinking as a Team Practice
The patterns above are only sustainable if the team shares the mental model. We maintain a short internal document called the "tenant contract" — a one-page description of what is shared, what is tenant-scoped, and what is explicitly forbidden to cross that boundary. Every new feature that touches the Website Builder starts with a question: does this respect the tenant contract?
The document does not need to be long. It needs to be authoritative. If engineers are unsure whether something violates it, that uncertainty is itself a sign the contract needs to be clearer.
Key Takeaways
- Make the shared/tenant boundary explicit — implicit boundaries become incidents
- Model configuration as a predictable three-layer merge: global, plan, tenant
- Scope CSS via custom properties on a tenant root attribute — the most performant isolation strategy
- Keep the routing layer as the only component aware of the resolution strategy
- Enforce no global side effects in renderer components — this is a hard rule, not a guideline
- Document the tenant contract and make it the entry point for every new feature discussion