DEV Community

Cover image for How to Build White-Label SaaS Without Creating a Code Fork for Every Customer
Vic Tekys for Devico Solutions

Posted on

How to Build White-Label SaaS Without Creating a Code Fork for Every Customer

I still remember the pull request that created release/acme-corp. One enterprise customer needed a different label format on their shipping documents. The deadline was tight, the config system didn't cover it, and branching felt like the pragmatic move. Everyone agreed it was temporary.

Eleven months later we were fixing the same date-parsing bug twice, in two codebases, with two QA passes — and the second fix shipped three weeks after the first because nobody remembered the fork existed until Acme filed the ticket.

Here's the position I'll argue: a fork is a code decision, not a deployment decision. Teams fork because they never sat down and named which layers actually vary per customer. Name them — branding, data model, workflow, integrations, infrastructure — give each one an explicit extension mechanism, and per-customer work collapses into configuration against one codebase. Even when a customer demands their own servers.

What "white-label" means to the customer

"We need white-labeling" is one of the least precise sentences a prospect can say. In my experience it decodes into four different requests, at four wildly different costs:

  1. A logo. Their brand on your product: colors, name, domain. Cheap if you built for it, a fork factory if you didn't.
  2. A data boundary. "Our data can't sit next to anyone else's." An isolation requirement, not a code requirement.
  3. Their workflow. Approval steps, field validation, document formats that differ from your default. This is the expensive one.
  4. Their integrations. "We use carrier X and SSO provider Y." Every enterprise deal comes with this rider.

Real customers ask for all four. On the E2EE mobile messenger we've worked on since 2020 — a Geneva-based product for privacy-focused enterprises, 6 people on dev and QA — the product ships customizable branding options to align with each client's unique identity. On the carbon management platform we build for a German client, the product has to integrate with the ESG, GRC, compliance, and supply-chain software its customers already run.

Both of those are published demand, not published mechanism — the case pages tell you what customers ask for, not how to implement it. The implementation opinions below are mine.

The point of the taxonomy: classify the ask before estimating it. A logo request priced like a workflow request loses the deal; a workflow request priced like a logo request loses your margin, and eventually your architecture.

Branding is the cheap layer — keep it that way

Branding stays cheap under exactly one condition: it lives in runtime configuration, not build-time flags. The moment someone writes #ifdef ACME — or its JS equivalent, if (BUILD_TARGET === 'acme') — you've started a fork, just distributed across the codebase instead of across branches.

The mechanics that have worked for me:

  • Resolve the tenant from the host header, once, in middleware. acme.yourapp.com or app.acme.com maps to a tenant ID; everything downstream reads config, never hostnames.
  • Ship theme as data: design tokens, logo URLs, product name, email sender, legal footer — one JSON document per tenant.
  • Always render a default. Missing config must produce your stock brand, not a crash. Tenant onboarding will forget a field; that can't be a sev-2.

// theme resolution: config, not conditionals

async function resolveTheme(hostname: string): Promise {

const tenant = await tenants.byHostname(hostname); // cached, obviously

return { ...DEFAULT_THEME, ...(tenant?.theme ?? {}) };

}

The spread against DEFAULT_THEME is the load-bearing line. Every tenant override is partial; the default absorbs whatever onboarding missed.

One warning from the trenches: branding stops being cheap the moment a customer asks to move things — "can the sidebar go on the right," "we want approvals before the summary screen." That's not theming. That's a workflow request wearing a theming costume, and it needs to be priced and architected as one.

Embedded surfaces are a tenant boundary

There's a surface everyone forgets when listing what varies per customer: the part of your product that runs inside someone else's website.

We've been on Xola, a booking platform for tour and activity operators, since 2021 with 11 engineers across the USA and India. One of its core products is a checkout module — an app embedded into a reseller's website so that their clients can purchase tours and activities right on that website. And per-customer integrations there include Google Analytics, Facebook pixel, MailChimp, and Zoom.

An embedded widget inherits everything about its host page:

  • the host's CSS resets and global styles,
  • the host's Content-Security-Policy,
  • the host's analytics and consent tooling,
  • whatever ancient jQuery the host still loads.

If your widget's styling or script execution depends on assumptions about the surrounding page, every new reseller is a new integration project — and integration projects that touch core code are how forks get born. Isolate the surface instead: an iframe when you can afford the postMessage tax, shadow DOM when you need to live in the host's document. (That's my reasoning, to be clear — the Xola case study documents the embedded checkout and the integrations, not the isolation technique.)

Treat the embed like a public API with a version, not like a page you own. The reseller's site is production you don't control.

Integrations are where forks are born

If I had to name the single largest source of per-customer branches across projects I've seen, it's this sentence: "Customer X uses carrier Y."

The naive implementation adds if (tenant === 'x') { fedexClient.ship(...) } somewhere in the order flow. The second carrier adds an else if. The fifth carrier — with its slightly different retry semantics and label format — is when someone proposes "a dedicated build for the logistics-heavy customers," and now you're forking.

The scale of the problem is real. Sway, a US logistics company (formerly Returnmates) where a dedicated team of 7 Devico engineers has worked since 2020, serves more than 250 brands — Rent The Runway among them — through one unified tracking system spanning FedEx, ClearJet, Million, T-Force, SmartKargo, and UDS, plus Stripe for payments, Google Maps and AWS Location Service for geo, and Zebra ZPL label generation. Six carriers, 250+ brands, one system. (The case study lists the integrations; the adapter design below is my prescription, not a description of Sway's internals.)

The pattern that keeps this from forking is old and boring, which is why it works:

  1. Define one internal interface per vendor category — Carrier, PaymentProvider, SsoProvider.
  2. Implement each vendor as an adapter behind that interface.
  3. Select the adapter per tenant from config, at one composition point.
  4. Contract-test every adapter against the same suite, so "works for tenant X" is provable without tenant X.

interface Carrier {

createShipment(o: Order): Promise;

track(id: string): Promise;

renderLabel(s: Shipment): Promise; // ZPL, PDF, whatever

}

const carriers: Record = {

fedex: new FedExAdapter(),

smartkargo: new SmartKargoAdapter(),

};

function carrierFor(tenant: TenantConfig): Carrier {

return carriers[tenant.carrier] ?? carriers[DEFAULT_CARRIER];

}

The discipline is in what the interface excludes. The moment Carrier grows a fedexSpecificOptions field, tenant knowledge has leaked into core code and the boundary is decorative. Vendor quirks live inside the adapter or they live everywhere.

Budget honestly for this: each adapter is real work — auth, rate limits, sandbox environments, webhook shapes. But it's work that lands in an isolated, contract-tested module instead of in if statements sprayed through your order pipeline.

Per-tenant infrastructure, not per-tenant code

Now the assumption I most want to kill: "separate infrastructure per customer means a separate build." It doesn't. Isolation is an infrastructure-as-code concern, and IaC takes parameters.

Compliance is the usual driver of these demands, and it's not exotic. Arklign, a US dental technology platform where a 7-person Devico team has worked since 2016, runs lab fabrication, logistics, and data workflows across distributed dental practices in full HIPAA compliance — and regulated verticals like that are exactly where a customer's counsel eventually asks where, physically, the data lives. (That last inference is mine; the case study documents the compliance, not the question.)

The carbon management platform I mentioned earlier is the cleanest example I've shipped. The client — Germany, working with 3 Devico full-stack devs since 2023 — required white-labeling with deployment on a separate host and superfast creation of distinct infrastructure per client, while moving from a basic app reliant on numerous third-party services to an independent solution with proprietary formulas and algorithms. The stack: TypeScript, React, NestJS, Prisma, PostgreSQL, and AWS Lambda, SNS, SQS, DynamoDB, and Cognito, defined with the AWS TypeScript CDK (plus SAM).

The CDK is what makes "separate host" compatible with "one repo": infrastructure configurations deploy on whatever cluster fits the client's location. A tenant is a stack instantiation, not a branch:

// one stack definition, N instantiations — the whole trick

for (const tenant of tenants) {

new PlatformStack(app, platform-${tenant.id}, {

env: { account: tenant.awsAccount, region: tenant.region },

dataResidency: tenant.region,     // eu-central-1 for the German clients

cognitoPool: tenant.isolatedAuth, // per-tenant user pool if required

});

}

Same application code, same serverless pipeline — on this platform, clients upload bulk CSV data that gets validated and processed through staged serverless services — but each customer gets their own host, their own data boundary, their own region. Residency, compliance, and noisy-neighbor requirements: satisfied. Fork: avoided. The client secured investment in the project's early stages; I'll claim no engineering metric beyond that, because none is published.

Full disclosure on the cost, because this is where the model hurts:

  • N stacks means N migration runs, and migration N-3 will fail differently than the others.
  • N monitoring targets, N alert routes, N dashboards.
  • N versions in the wild unless you enforce a deploy train — version drift between tenant stacks is the fork problem sneaking back in through the ops door.

Parameterized infrastructure is still a fleet. You've traded a code-merge problem for an ops problem. I take that trade every time, but it's a trade.

When a fork is right

After all that: sometimes a fork is the correct call. I can defend exactly three cases.

  1. The domain model diverges permanently. Not a field or a workflow — the actual entities and their relationships stop being the same product. Config can't paper over a different domain.
  2. Contractual isolation forbids shared code paths. Some regulated deals require it in writing. You can't architect around a signature.
  3. The account funds its own roadmap. If one customer pays for a dedicated team and a divergent feature stream indefinitely, you don't have a customer — you have a second product. Staff it like one.

Even then, two conditions or you're just deferring the Acme story: the fork gets a named owner whose job includes syncing shared fixes, and a sunset date — a scheduled decision point to merge back, spin off, or kill. A fork without an owner and a date isn't a strategy. It's a slow leak with a branch name.

The cost of configurability

Fair's fair: I've spent this whole article arguing against forks, so let me argue against my own medicine for a minute. Configuration isn't free — it just bills differently.

Every switch you add multiplies the test matrix. Ten boolean tenant options is a theoretical 1,024 configurations; nobody tests that, and nobody should pretend to. Worse, tenant-specific bugs are miserable to repro: the bug lives in one tenant's combination of theme, workflow config, and adapter selection, and your local environment has none of it.

No vendor I know of publishes defect rates tied to configuration complexity, so I can't hand you a stat here — this is argued from scars, not data. What's kept the matrix survivable for me:

  • Test the default tenant plus the two most divergent ones. Not every tenant — the default and the extremes. The extremes are where the spread operators and fallbacks actually get exercised.
  • Contract tests per adapter, so every vendor implementation proves the same behavioral guarantees without a live tenant in the loop.
  • Version tenant config as a reviewable artifact. Config in a repo, changed by PR, deployed like code. A tenant's config diff should be as reviewable as a code diff, because it changes production behavior exactly like one.

That last one matters more than it looks. If config lives in a database row edited by hand, you have all the risk of a fork with none of the git history.

Order of operations

If you're staring at your first "we need white-label" deal, this is the sequence I'd run:

  1. Enumerate the variation layers for this customer: branding, data boundary, workflow, integrations, infrastructure. Write them down; price them separately.
  2. Give branding runtime tokens with a default fallback — and refuse to let layout changes hide in the theming budget.
  3. Put every vendor behind one internal interface with per-tenant adapter selection, contract-tested.
  4. Parameterize infrastructure so isolation and residency are stack parameters, not repo branches.
  5. Only then, if the domain model itself is diverging, consider a fork — with a named owner and a sunset date attached before the branch is created.

Reach for this when you're a B2B SaaS with a growing roster of enterprise or reseller customers who each arrive with a logo, a compliance rider, and a favorite vendor. Skip the machinery when you have two customers and no divergence yet — speculative extension points rot as fast as forks do.

And one operational habit, because config that nobody exercises is config that's silently broken: the tenant that breaks in production is always the one whose configuration nobody has rendered since onboarding. Boot every tenant's config in CI — default theme, adapter wiring, stack synth — even the quiet tenants. Especially the quiet tenants.

Top comments (0)