How to build a multi-tenant SaaS
Tenant isolation, auth, billing, and the scaling traps most teams hit. An honest field guide to building a multi-tenant SaaS that survives growth.

Almost every SaaS is multi-tenant, whether the founder uses that word or not. The moment two different companies log into the same app and each sees only their own data, you have a multi-tenant system. The hard part is not the idea. The hard part is making sure Company A never, ever sees Company B's data, and keeping that true through two years of new features, new engineers, and 3am deploys.
This guide is the version we would give a founder over coffee: what to build, what to skip, where the real work hides, and what it costs.
What it is and who it is for
A multi-tenant SaaS is one codebase and (usually) one database serving many customer organizations at once. Each organization is a "tenant." Their users, data, and settings live alongside everyone else's, separated by software rather than by running a fresh copy of the app per customer.
The core job is simple to say and brutal to get wrong: isolate tenants completely while sharing infrastructure efficiently. Get isolation wrong and you have a data breach. Get sharing wrong and your hosting bill scales linearly with customers, which kills the economics that made SaaS attractive in the first place.
This is for founders selling software to businesses, where each customer is a company with multiple seats. If you are building a consumer app where every user is an island, you do not need most of this. You need it the day your buyers ask "can my whole team get in" and "can I see who did what."
The MVP feature set
Build first:
- Tenant model and isolation. Every row of business data carries a
tenant_id, and every query filters on it. This is the foundation. We get concrete below. - Auth scoped to a tenant. A user belongs to one or more organizations. Logging in establishes who you are and which tenant you are acting inside.
- Basic roles. Owner, admin, member is enough to start. Owner changes billing, admins manage people, members do the work.
- Invites. How a tenant grows its team. Email an invite, they accept, they land in the right org.
- Billing per tenant. The organization pays, not the individual user. Seats or usage, billed to the company.
Build later:
- Custom roles and granular permissions. Real, but most early customers are fine with three roles.
- SSO and SAML. Enterprise will demand it. Nobody in your first ten customers will.
- Per-tenant custom domains, white-labeling, and theming. Lovely, not MVP.
- Audit logs and data export. Worth it, and easier if you design the schema for it early, but not week one.
- Schema-per-tenant or database-per-tenant isolation. Start shared. More on this trap below.
The mistake we see most is teams building the fancy permission system and white-label theming before a single tenant will pay. Ship the boring isolated core, sell it, then earn the rest.
The hard parts most teams underestimate
Isolation strategy is close to a one-way door. You have three broad choices, and they trade simplicity for blast radius:
- Row-level (shared database, shared schema). One
tenant_idcolumn on every table. Cheapest to run, easiest to query across tenants for analytics, and what we reach for by default. The risk is a forgottenWHERE tenant_id = ...leaking data. - Schema-per-tenant (shared database, separate schemas). Each tenant gets its own set of tables. Stronger separation, but migrations now run N times and connection management gets fiddly.
- Database-per-tenant. Maximum isolation, often required in regulated industries. Also maximum operational pain: provisioning, migrating, and backing up hundreds of databases is a full job.
Most SaaS companies are best served by row-level, enforced in one place rather than trusting every developer to remember. The honest version of this trade is in our multi-tenant architecture deep dive, but the short answer is: start shared, isolate harder only when a real customer or regulator forces it.
Enforcing the tenant filter where it cannot be forgotten. Hoping every query includes tenant_id is how breaches happen. Postgres has a real answer: row-level security (RLS). You set a session variable on each request, and the database itself refuses to return another tenant's rows.
-- Every tenant-scoped table carries the tenant id
create table projects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id),
name text not null,
created_at timestamptz not null default now()
);
create index on projects (tenant_id);
-- Turn on row-level security and force it for table owners too
alter table projects enable row level security;
alter table projects force row level security;
-- A row is only visible if it matches the current request's tenant
create policy tenant_isolation on projects
using (tenant_id = current_setting('app.tenant_id')::uuid);Then in your app, every request opens a transaction and sets the tenant before touching data:
set local app.tenant_id = '4b1c...'; -- from the authenticated sessionNow a forgotten filter returns zero rows instead of someone else's data. The database is the backstop, not the application code. This is the single most valuable decision in the whole build.
Billing is per organization, and that breaks naive assumptions. Stripe (or whoever) bills the tenant, not the logged-in user. You map a Stripe customer to a tenant, handle seat counts as people are invited and removed, and decide what happens when a card fails: read-only mode, grace period, or lockout. Webhooks keep plan and seat state in sync, and they will fire out of order, so make your handlers idempotent. We dig into this in Stripe integration work; for now, just know billing state is its own source of truth you have to reconcile.
The per-tenant config surface grows quietly. Feature flags per plan, limits per tenant (max projects, max seats, API rate), branding, and early-access toggles. Put this behind a single settings object per tenant from day one. Scatter it across fifteen boolean columns and you will hate yourself by customer fifty.
The stack we would reach for and why
Nothing exotic. The exotic choice is the one that hurts at scale.
- Next.js and TypeScript for the app, because one framework handles the marketing site, the dashboard, and the API routes, and types catch a class of "wrong tenant" bugs before they ship.
- Postgres for data, because RLS is exactly the feature multi-tenancy needs, and it is rock solid. We explain the bias toward it across our SaaS platform work.
- A managed auth provider (Clerk, WorkOS, or Auth0) that understands organizations natively. Do not build org-based auth yourself. The edge cases around invites, role changes, and SSO will eat months.
- Stripe for billing, with webhooks driving your internal subscription state.
- A background job queue for slow tenant-level work: provisioning, exports, invite emails, reconciling billing.
If you are choosing tools and a partner rather than writing code, our SaaS development page lays out how we approach this kind of build.
Rough timeline and cost
These are honest ranges, not quotes. Every "it depends" applies.
- Isolated core (tenants, auth, roles, invites, one real feature): roughly 4 to 7 weeks.
- Billing, the config surface, and a polished dashboard: another 4 to 8 weeks.
- A sellable v1 that a real customer pays for: call it 2 to 4 months of focused work.
In money, a serious v1 from a senior team tends to land in the low-to-mid five figures and up, depending on how much of the "later" list creeps into "now." Enterprise requirements (SSO, audit logs, database-per-tenant) push it higher. The cheapest version is almost never the cheapest over two years, because rework on a leaky tenant model is the most expensive bug there is.
What to watch out for
- Migrations that forget a tenant. With row-level isolation this is fine. With schema-per-tenant, a migration that half-runs across hundreds of schemas is a bad afternoon. Use a runner that is transactional and resumable before you go that route.
- The "noisy neighbor." One tenant runs a giant report and everyone else slows down. Watch slow queries by tenant and rate-limit expensive operations early.
- Analytics queries that bypass RLS. Internal dashboards often need to read across tenants. Give them a separate, clearly-marked connection so you never accidentally relax isolation in the customer-facing path.
- Seat-count drift. Invites, deactivations, and billing seats fall out of sync if you do not reconcile them. Derive the billed seat count from active users, not a number you increment by hand.
Takeaway
A multi-tenant SaaS is not hard because of any one feature. It is hard because isolation has to stay true forever, billing lives at the org level, and the config surface grows behind your back. Get the tenant model and database-level guardrails right early, keep the MVP boring, and earn the fancy parts once a customer is paying.
This is squarely the kind of system we build and take over: multi-tenant products that have to be correct on day one and still correct after two years of features. If you are starting one, or untangling one that grew faster than its foundations, tell us what you are building and we will give you the honest version.
