One Codebase, Any Number of Brands: Rewriting Travelstart's B2C Site as a Nuxt App for Multiple Tenants
Travelstart already had a B2C website in production, an Angular app that had been around for years. It wasn't one clean monolith either, it was what I'd call a distributed monolith: split across several deployable pieces on paper, but coupled tightly enough underneath that changing one piece safely still meant understanding all the others. You got none of the benefit people usually reach for when they split a system apart, and most of the coordination cost of a monolith anyway. What it definitely wasn't built for was serving more than one brand, and the business didn't want a number in mind for how many brands it might eventually need to support. Retrofitting support for a number of tenants with no fixed ceiling onto that existing, already tightly coupled Angular codebase looked harder and riskier than rebuilding on a stack chosen for it. So that's what my team did: a full rewrite on Nuxt, with support for multiple tenants treated as essential from the first commit rather than something bolted on afterward.
The obvious shortcut was the one I see most setups built to serve multiple brands reach for: a shared component library, then a repo or a folder per brand that overrides colors, layouts, and copy. I didn't want that. Every new folder is another place to apply the same fix, another place for behavior to quietly drift apart, and eventually someone shipping a change to one brand and forgetting the rest exist. That approach also doesn't scale the way the business needed it to, because the real requirement wasn't "support three brands," it was "support N brands," and N was never given a ceiling. Onboarding brand four or brand forty had to be a config change, not a new folder and a new deploy pipeline. I decided from day one that there would be exactly one codebase no matter how many brands sat on top of it.
finding out who actually knew the answer
Before any of the architecture decisions, there was a much less glamorous problem: a lot of how the old system actually behaved wasn't written down anywhere. It lived in a designer's memory, or a product owner's, or in whichever engineer had been around long enough to remember why a particular edge case had been handled the way it had. Rebuilding the thing properly meant tracking that knowledge down first, sitting with designers to understand intent behind decisions that had never been documented, asking product owners why a flow worked the way it did, and going between teams that each held a different piece of the picture and none of whom had the whole thing. A distributed monolith makes this worse, not better. Splitting a system across services without splitting the knowledge about it the same way just means the tribal knowledge is scattered across as many teams as the code is.
one core layer is the whole app, not a layer among many
Nuxt's layers feature is normally how you'd build this kind of thing: a base layer with shared logic, then a layer per tenant that extends it with overrides specific to that brand. We built one layer instead, as a team decision I pushed for and the rest of the team backed. The root config says everything about the decision:
// nuxt.config.ts export default defineNuxtConfig({ extends: ["./layers/core"], });
That core layer isn't a shared foundation that brand layers sit on top of. It's the entire app, and on purpose it has no idea which tenant it's serving. Every difference that depends on the tenant, theme, layout, which features are switched on, page content, comes from a separate service we built alongside it, resolved at runtime. There's no layer per tenant to keep in sync, because there's no layer per tenant at all.
Which tenant is rendering is set through one environment variable per deployment:
PUBLIC_TENANT=travelstart
PUBLIC_TENANT=tenant-a
and that variable does almost nothing by itself. It's a key the app hands to the other service so it knows what to ask for.
a separate service for pulling tenant differences out of the codebase entirely
We built a dedicated tenant configuration service as its own separate app rather than a folder inside the main site, specifically so that changing a brand's look or turning a feature on or off would never require touching the Nuxt app or waiting on a deploy. That service publishes tenant configuration, branding tokens, layout choices, feature flags, page content, and the main site reads whatever's published at request time. There's a visual editor on that side with an iframe preview, so someone can see a brand change rendered before it goes live.
The part I underestimated at the start was that the config's own shape would need to change over time, and every previously published config still has to resolve correctly against whatever the current schema expects. I ended up writing a normalization step for exactly that. It's not glamorous work, but it's the same problem an API has with old clients, and pretending tenant config would never need to evolve would have been the wrong bet.
Inside the main app, tenant differences are only ever expressed through config and design tokens read via a composable, never through conditionals scattered across components. I was strict about this rule specifically because I know how it fails: the first time someone writes if (tenant === "tenant-a") inside a component under deadline pressure, that becomes the pattern, and six months later nobody can tell you which brand differences are real product decisions and which are just where someone happened to need a shortcut.
building the sandbox for a bank partner that wanted the flow inside its own app
Tenant A, a bank partner, wasn't a reskin. They wanted their own banking app's customers to book flights without ever leaving the app, which meant the flight booking flow had to run inside a native WebView, not open in a browser tab.
Partner engineering teams weren't all going to be on the same mobile stack, so instead of building one wrapper and hoping every partner matched it, I built reference sandboxes covering the stacks partners actually show up with, React Native, Flutter, and Capacitor with Ionic, each embedding the Nuxt site behind a native WebView the same way a real partner app would, plus a plain iframe path for a host that doesn't own the WebView directly. The identity problem was the interesting part: on the very first page load there's no cookie and no session yet to say which tenant this is. Each WebView sandbox sets a tenant identifier header on that first request, and I wrote a small endpoint on the main site whose only job is reading that header, so a real native client setting headers on initial load and a plain browser hitting the same route both resolve to the correct tenant.
I also wrote the spec covering this handshake from end to end, with a comment stating exactly what it does and doesn't cover: it verifies the main site's side, not the sandboxes, not a real native WebView. There's no way to drive an actual WebView shell from a Playwright test running against the website alone, so faking that side would have meant a green test that verified nothing about the one thing most likely to break. I'd rather have an honest gap documented in the test than a false sense of coverage.
what this design cost me, and what it bought back
The payoff is real. A fix or a feature ships once and every brand gets it the next time the tenant service's config resolves. Nobody is opening a nearly identical pull request per brand depending on who asked first, and adding brand ten costs the same as adding brand two: a new tenant config, not a new code path. The codebase itself has no idea how many tenants exist. It just asks the tenant service for one at a time, which is really the whole point, N was never a number I needed to know in advance.
The cost is a discipline I have to keep enforcing, on myself as much as anyone else. Every new feature has to be built as something the tenant service can toggle, or as something genuinely universal. Under deadline pressure the temptation is always to hardcode one exception just this once. The whole design only holds up if that temptation loses, every time.