Building a Multi-Tenant SaaS on a Single Supabase Database
How I run seven branded sites — one Next.js codebase, one Supabase project. A practical walkthrough of hostname resolution, feature flags, data isolation with RLS, and tenant-aware routing.
How I run seven branded sites — one Next.js codebase, one Supabase project. A practical walkthrough of hostname resolution, feature flags, data isolation with RLS, and tenant-aware routing.
The standard answer to "I need multiple branded sites" is multiple codebases — one repo per brand, each deployed separately. It works, but it scales poorly. Bug fixes get applied to three repos instead of one. A new feature requires four deployments. Shared logic drifts out of sync. The maintenance overhead compounds fast.
The alternative is a single codebase that resolves its identity at runtime from the incoming request. One deployment, one database, one set of tests — but any number of distinct front-ends.
I use this pattern across seven live production sites built on the ExpertSapiens platform: expertsapiens.com, mrvisakorea.com, apostillefirst.com, seoulhomes.kr, seoultranslate.com, airlinkee.com, and rehovica.com. Each looks, behaves, and ranks as its own brand. They share one Next.js codebase and one Supabase project.
This post covers how it's built.
The foundation is a TenantConfig object — a fully typed description of what a tenant is and what it can do. Every piece of tenant-specific behavior flows from this object, not from environment variables or string comparisons scattered through the code.
export type TenantId =
| "expertsapiens"
| "apostillefirst"
| "mrvisakorea"
| "seoulhomes"
| "seoultranslate"
| "link"
| "rehovica";
export interface TenantConfig {
id: TenantId;
name: string;
baseUrl: string;
tagline: string;
categoryFilter: string[] | null; // null = show all categories
siteKey: string | null; // used to filter DB rows
locales: readonly string[];
gaId: string | null;
features: TenantFeatures;
// convenience booleans — computed, never set manually
isExpertSapiens: boolean;
isMrVisa: boolean;
isApostille: boolean;
// ...
}
A createTenant() factory function merges per-tenant overrides with shared defaults and computes the convenience booleans automatically:
function createTenant(input: TenantInput): TenantConfig {
return {
...input,
features: { ...FEATURE_DEFAULTS, ...input.features },
isExpertSapiens: input.id === "expertsapiens",
isMrVisa: input.id === "mrvisakorea",
isApostille: input.id === "apostillefirst",
// ...
};
}
This means adding a new tenant is a config entry, not code changes. The type system enforces completeness — if you miss a required field, TypeScript tells you before the build finishes.
Each tenant exposes a distinct surface area. ExpertSapiens has the full feature set. MrVisaKorea shows visa guides and hides the property listings. ApostilleFirst has no expert directory — it's a document legalization service, not a marketplace.
Rather than scattering if (tenant.id === "mrvisakorea") checks everywhere, I use a TenantFeatures interface with every flag defaulting to false. Each tenant only enables what it needs:
const FEATURE_DEFAULTS: TenantFeatures = {
showVisaGuides: false,
showApostilleGuides: false,
showHireGuides: false,
showExpertDirectory: false,
showPropertyListings: false,
showCurrencySelector: false,
// ... all false by default
};
// mrvisakorea overrides only what it needs:
features: {
showVisaGuides: true,
showKVisaWidgets: true,
showExpertDirectory: true,
showGlossary: true,
indexContentGuides: true,
}
Components check tenant.features.showVisaGuides rather than the tenant ID. The flag name expresses intent; the ID is implementation detail. This also makes it easy to toggle features across tenants without touching component code.
Tenant resolution happens once — in Next.js middleware — before any page renders. The middleware reads the request hostname and calls getTenantByHostname():
export function getTenantByHostname(hostname: string): TenantConfig {
if (matchesDomain(hostname, "apostillefirst.com")) return TENANTS.apostillefirst;
if (matchesDomain(hostname, "mrvisakorea.com")) return TENANTS.mrvisakorea;
if (matchesDomain(hostname, "seoulhomes.kr")) return TENANTS.seoulhomes;
if (matchesDomain(hostname, "seoultranslate.com")) return TENANTS.seoultranslate;
if (matchesDomain(hostname, "airlinkee.com")) return TENANTS.link;
if (matchesDomain(hostname, "rehovica.com")) return TENANTS.rehovica;
return TENANTS.expertsapiens; // default
}
The resolved tenant is forwarded as an x-tenant header and picked up by server components via headers(). No prop drilling, no context providers wrapping the entire app — every server component can resolve its tenant in one line.
For local development, a NEXT_PUBLIC_TENANT_OVERRIDE env var (or a cookie set by an in-app dev switcher) lets you browse any tenant on localhost without changing the hostname.
All tenants share one Supabase project. The experts table has a sites column — a text[] array of which tenant keys that expert belongs to. Tenant-scoped queries filter with sites @> ARRAY['mrvisakorea'], using a GIN index on the column for performance.
-- Experts visible to MrVisaKorea
SELECT * FROM experts
WHERE sites @> ARRAY['mrvisakorea']
AND is_active = true;
This means a single expert can appear on multiple tenants — or none. The admin panel has per-expert toggles. No duplicated rows, no cross-tenant joins.
Row-Level Security policies enforce the same isolation at the database layer:
-- Users can only read their own data regardless of which tenant they're on
CREATE POLICY "users_own_data" ON bookings
FOR SELECT USING (client_id = auth.uid());
RLS means even a bug in application code can't return another user's bookings. The enforcement happens in Postgres, not in a middleware function that could be bypassed.
Some tenants have entirely different page universes. MrVisaKorea has visa guide pages that don't exist on ExpertSapiens. ApostilleFirst has apostille service pages. SeoulHomes has property listings.
categoryFilter on the tenant config handles this at the routing layer — if a tenant has categoryFilter: ["immigration", "legal"], then /category/[slug] returns 404 for any other slug. No accidental content leakage across tenant surfaces.
For selecting per-tenant values in components, a selectByTenant() utility keeps the code clean:
const keywords = selectByTenant(tenant, {
mrvisakorea: ["Korea visa consultant", "immigration lawyer Seoul"],
apostillefirst: ["apostille Korea", "document legalization"],
default: ["expert services", "professional directory"],
});
This is more readable than a switch statement and fully typed — TypeScript will error if you reference a TenantId that doesn't exist.
Seven live sites. One vercel deploy. When I fix a bug in the booking flow, all seven tenants get the fix simultaneously. When I add a new page type, I add one feature flag and enable it on the tenants that need it.
The tradeoff is complexity in the config layer and careful discipline around data isolation. The config is verbose — each TenantConfig object is ~40 lines — but it's all in one file (src/lib/tenant.ts) and the TypeScript compiler catches any inconsistencies.
For projects where each client needs meaningfully different behavior but shares a common platform, this pattern is significantly more maintainable than separate repositories.
tenant.id directly in components; flags are more refactorableIf you're building a platform that needs to support multiple brands, clients, or subdomains from a single codebase — this is a pattern worth considering. I've used it across multiple production projects and it's held up well.