One Codebase, Many Brand Identities
One Codebase, Many Brand Identities
At my last job I worked at a company that builds AI-powered damage assessment software for the automotive insurance industry. Insurers, fleet operators, leasing companies, and body shops all use the platform — each as their own branded product with their own domain and feature set. Early on we had a version per partner, essentially a fork. Any shared bug fix meant touching several repos. Any new feature meant releasing it multiple times.
The obvious move was one codebase with a runtime identity layer. Same components, same logic, different config per partner. Here’s how that works in practice and where it got complicated.
Tenant resolution
We use Vike for SSR (a Vite plugin that adds server-side rendering without forcing you into a specific framework). On each incoming request, middleware reads the hostname and resolves which tenant is active. That tenant’s config is loaded from a JSON file and injected into the React tree via context.
request → hostname → /config/{tenant}.json → TenantContext → components
Components never hardcode brand values. A button uses the active tenant’s primary color from context. The logo renders the tenant’s asset. Feature availability comes from a flags object in the config.
Adding a new partner is a JSON file and some brand assets. No code changes required.
Babylon.js in SSR
The platform includes a 3D damage viewer built with Babylon.js — users click on an interactive vehicle model to annotate where damage occurred. This was the part that caused the most trouble.
Babylon.js is browser-only. It expects window, document, and WebGL to exist. Vike renders on the server first, then hydrates in the browser. Running Babylon.js during SSR crashes immediately.
The fix was lazy initialization — the viewer renders a placeholder during SSR, then initializes Babylon.js after hydration inside a useEffect. That part is straightforward. The tricky part was making sure nothing in the import chain touched browser globals during the server pass. A single window reference buried three levels deep in an imported utility would kill the SSR render. We ended up isolating all Babylon-related imports behind dynamic import() calls that only run on the client.
On top of the SSR issue, different partners had different vehicle fleets and different damage taxonomies. The 3D models, hotspot positions, and part IDs all had to come from tenant config rather than being hardcoded. A partner focused on commercial vehicles needed completely different models from one focused on passenger cars.
WebSocket event routing
Dashboards show live data — claim status updates, pipeline events, worker heartbeats. The frontend maintains a persistent WebSocket connection and updates widgets as events arrive.
With multiple partners potentially active on the same infrastructure, the server-side routing had to be tenant-scoped. An event for Partner A must not reach Partner B’s dashboard. We enforce this at the connection level — each WebSocket connection is authenticated and tagged with a tenant ID on open, and the server filters events against that tag before pushing.
This sounds obvious, but it’s the kind of thing that’s easy to skip in development when there’s only one tenant and painful to retrofit later once you’ve got real production traffic mixing.
Config schema drift
One thing we didn’t handle well early on: config files diverge over time. As we added features, we added fields to the config. Partners onboarded early had config files that were missing the new fields. Partners onboarded later had configs that were missing fields only the older partners used. After a while, the config schema was a mess that only worked because the code had enough fallbacks.
The fix was a versioned config schema with a validation step on startup. If a config file doesn’t conform, the server refuses to start. It’s annoying when you’re adding a new field and have to update every existing config file, but it’s much less annoying than a partner’s dashboard silently misbehaving because a field was missing.
What didn’t work the first time
The original tenant resolver was Express middleware that set a request-scoped variable. This worked fine for server-rendered pages but broke for client-side navigation — Vike’s client-side router doesn’t go through server middleware, so the tenant context disappeared after the first navigation.
The solution was to inject the resolved tenant config into the page’s initial HTML during SSR (as a serialized JSON blob in a script tag) and read it from there on the client. The server pass resolves the tenant; the client hydrates with the same config it was given. Navigation doesn’t need to re-resolve anything.
It took a few days to track down why things were breaking only after navigating away from the initial page. The fix itself was small.