White-label SaaS sounds like a buzzword until you're three weeks into building the same feature for the third client and realizing you're maintaining three codebases. The smarter move: one Velocity codebase, five brands, one deploy. Each client gets their logo, their domain, their Stripe account — but under the hood, you're pushing the same code to production. Brand identity becomes configuration. Tenant isolation (who sees what) is determined by subdomain or custom domain at request time. This isn't theoretical; it's the pattern Aidxn uses for multi-brand SaaS clients. One product, radically scalable billing and branding. Here's how to build it.
What Is White-Label SaaS, Really?
White-label SaaS means you build a product once and resell it under multiple brands. The client sees their logo, their colors, their domain name. They think it's custom-built for them. You know it's the same codebase deployed five times with different configuration.
This is different from multi-tenant SaaS (one app, many customers sharing infrastructure). In multi-tenant, all customers see the same branding but have isolated data. In white-label, different customers see completely different branding — they might not even know it's the same underlying product. Think Zapier vs Make: both no-code automation platforms, same architecture family, completely different visual identity and business models.
The ROI: You eliminate code duplication. Feature ships to all five brands at once. Bug fixes? One fix, five customers. Maintenance cost stays linear instead of exploding with each new brand. Pricing and billing are separate per brand (Client A pays $99/seat, Client B pays $149/seat), so you can negotiate freely without re-architecting.
Architecture: Subdomain Routing + brand.json
The core pattern is dead simple. Every request comes in with a subdomain or custom domain. You look that up against a configuration file. Configuration tells you which brand to render, which Stripe account to charge, and which users belong to this tenant.
Step 1: Subdomain Detection
A request hits client-a.yoursaas.com or client-b.yoursaas.com. An Astro middleware (or edge function) reads the subdomain and loads the brand config.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware((context, next) => {
const host = context.request.headers.get('host') || '';
const subdomain = host.split('.')[0]; // 'client-a' from 'client-a.yoursaas.com'
context.locals.brandId = subdomain;
context.locals.brand = loadBrand(subdomain);
return next();
});
function loadBrand(brandId: string) {
// Load from brand.json or a Supabase lookup
const brands: Record<string, BrandConfig> = {
'client-a': {
name: 'Client A',
logo: '/logos/client-a.svg',
colors: { primary: '#0066ff', secondary: '#00cc88' },
stripeAccount: 'acct_client_a_12345',
domain: 'app.clienta.com', // Custom domain override
},
'client-b': {
name: 'Client B',
logo: '/logos/client-b.svg',
colors: { primary: '#ff6600', secondary: '#ffcc00' },
stripeAccount: 'acct_client_b_67890',
domain: 'platform.clientb.com',
},
};
return brands[brandId] || null;
}
Step 2: Custom Domain Support
Some clients want their own domain (app.clienta.com instead of client-a.yoursaas.com). You can support both. Maintain a reverse lookup: if the domain is custom, map it back to the brand ID. Store this in Supabase or a static JSON file that you update on deploy.
// domains.json
{
"app.clienta.com": "client-a",
"platform.clientb.com": "client-b",
"yoursaas.com": "marketing" // main site
}
// In middleware
function resolveBrandFromDomain(host: string): string | null {
const domainMap = loadJSON('/domains.json');
return domainMap[host] || null;
}
Step 3: Context Everywhere
Once you've resolved the brand, pass it through context so every component can read it. In Astro, use context.locals. In React islands, pass it as a prop or context provider.
// src/components/layout/Header.tsx
import { useEffect, useState } from 'react';
type Props = {
brandId: string;
};
export default function Header({ brandId }: Props) {
const [config, setConfig] = useState<BrandConfig | null>(null);
useEffect(() => {
// Fetch brand config from server or window context
fetch(`/api/brand?id=${brandId}`).then(r => r.json()).then(setConfig);
}, [brandId]);
if (!config) return null;
return (
<header style={{ background: config.colors.primary }}>
<img src={config.logo} alt={config.name} />
<h1>{config.name}</h1>
</header>
);
}
Tenant Isolation: RLS Per Brand
You've routed to the right brand. Now ensure that Client A's data is invisible to Client B. This is where multi-tenant isolation with RLS comes in. You add a tenant_id or brand_id column to every table and filter via Postgres policies.
The twist: instead of an organization_id, use brand_id (which maps to your subdomain). When a user logs in on client-a.yoursaas.com, their JWT claim includes brand_id: 'client-a'. All their queries automatically filter by that brand.
-- Schema
create table users (
id uuid primary key default gen_random_uuid(),
brand_id text not null, -- 'client-a', 'client-b', etc
email text not null,
role text default 'member',
created_at timestamp default now()
);
create table projects (
id uuid primary key default gen_random_uuid(),
brand_id text not null,
name text not null,
created_by uuid not null references users(id),
created_at timestamp default now()
);
-- RLS policies
alter table users enable row level security;
alter table projects enable row level security;
create policy "users see only their brand" on users
for select using (brand_id = (select current_setting('app.brand_id')));
create policy "projects are brand-scoped" on projects
for select using (brand_id = (select current_setting('app.brand_id')));
When you authenticate, set the brand_id in the session:
// Set on login
const { data, error } = await supabase.auth.signInWithPassword({
email: userEmail,
password: userPassword,
});
// After login, set Postgres variable for RLS
await supabase.rpc('set_brand_context', {
brand_id: context.locals.brandId,
});
Separate Stripe Accounts Per Brand
Each brand has its own Stripe account. This means separate revenue streams, separate payment processing, and the ability to negotiate different rates per client. When Client A upgrades to "Pro", they're charged through acct_client_a_12345. Client B charges through a different Stripe account entirely.
Connect Accounts Pattern
The simplest approach: each client has a Stripe Connect account. You're the platform, they're the seller. You route payment intents to their account at checkout time.
// src/pages/api/checkout.ts
import Stripe from 'stripe';
export async function POST({ request }: { request: Request }) {
const { brandId, priceId } = await request.json();
// Look up brand's Stripe account
const brandConfig = loadBrand(brandId);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create(
{
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `https://${brandConfig.domain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `https://${brandConfig.domain}/pricing`,
},
{
// Route to the client's Stripe Connect account
stripeAccount: brandConfig.stripeAccount,
}
);
return new Response(JSON.stringify({ sessionId: session.id }), {
headers: { 'Content-Type': 'application/json' },
});
}
Handling Webhooks Per Brand
Stripe sends webhooks to one endpoint. You need to route them to the right brand's handler.
// src/pages/api/webhooks/stripe.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST({ request }: { request: Request }) {
const signature = request.headers.get('stripe-signature')!;
const body = await request.text();
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 });
}
const stripeAccount = (event as any).account; // Webhook includes the Connect account ID
// Route to the correct brand
const brandId = findBrandByStripeAccount(stripeAccount);
await handleStripeEvent(event, brandId);
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
Deployment: One Codebase, Five Branches
Since brand configuration is JSON-driven, you deploy once. The same code runs on all five subdomains. Configuration determines what each client sees.
If you need brand-specific code (Client B wants a custom dashboard component), use feature flags tied to brand_id. Or: maintain that client's brand config in a branch, but merge your core features upstream so all clients benefit.
// Feature flag example
if (context.locals.brand.id === 'client-b' && featureEnabled('custom-dashboard')) {
return <CustomDashboard />;
}
return <DefaultDashboard />;
Six FAQs
What happens if Client B figures out Client A's subdomain?
Doesn't matter. RLS policies isolate the data. Even if Client B's browser hits client-a.yoursaas.com, they're logged in as a Client B user. The JWT says brand_id: client-b. The Postgres policy filters queries by brand_id. Client B gets 0 rows. Subdomain is just routing; isolation is in the database.
Can I use one Stripe account for all brands?
Yes, if you don't need separate revenue streams. You'd just use different price IDs and product IDs per brand. But separate Stripe accounts per Connect account are cleaner for accounting and give each client visibility into their own transactions.
How do I handle shared features between brands?
Features live in the shared codebase. Deploy them once, and all brands get them. Use feature flags if a client needs to opt in. If Brand A demands a custom feature, either build it for all brands (ROI often justifies it), or maintain a branch for that client and cherry-pick back to main when possible.
What if a user switches brands mid-session?
Log them out and force re-login. The JWT is tied to brand_id at auth time. If they navigate to a different subdomain, they're in a new brand context and need to re-authenticate. Alternatively: issue a new JWT for the new brand, but keep users in separate silos—no cross-brand context switching.
How do I migrate a brand's data to its own database later?
Export all rows where brand_id = 'client-a', provision a new database, import them, and update the brand config to point at the new database. Codebase stays the same; you just swap the connection string. This is the scalability win of white-label: you can move a high-value client to dedicated infrastructure without touching the app.
Can I resell this to other agencies or SaaS companies?
Yes. You're building a platform. White-label architecture is exactly how you enable that. Each reseller gets their own subdomain/domain, their own Stripe account, and their own customer base. You maintain the codebase, they maintain the sales. Revenue splits are a commercial question, not an architecture one.
The Bottom Line
White-label SaaS is not about removing logos—it's about using configuration to replace code duplication. One codebase, subdomain routing, brand.json for identity, RLS for isolation, separate Stripe accounts for billing. You scale from one brand to five without multiplying maintenance cost. Features ship to all clients at once. Bugs get fixed once. This is how platforms scale revenue without exploding engineering complexity. Ready to architect white-label products that make money? Reach out at Aidxn Design for SaaS architecture and implementation partnerships.