How to Add Multi-Tenancy to Your SaaS (2026 Guide)
Every B2B SaaS needs multi-tenancy — the ability for multiple organizations to share a single application while keeping their data isolated. Get it wrong and you'll spend months refactoring. Get it right from the start and scaling is straightforward.
Multi-Tenancy Models
1. Shared Database, Shared Schema (Row-Level)
All tenants share the same database tables. A tenant_id column on every table identifies which organization owns each row.
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 DEFAULT NOW()
);
-- Every query must filter by tenant_id
SELECT * FROM projects WHERE tenant_id = 'org_123';
Pros:
- Simplest to implement and operate
- Single database to manage
- Easy cross-tenant analytics (when needed)
- Lowest infrastructure cost
Cons:
- Risk of data leakage if you forget
WHERE tenant_id = ... - Noisy neighbor problem (one tenant's heavy queries affect others)
- Harder to comply with data residency requirements
Best for: 90% of SaaS products. Start here unless you have a specific reason not to.
2. Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema within the same database.
CREATE SCHEMA tenant_org123;
CREATE TABLE tenant_org123.projects (
id UUID PRIMARY KEY,
name TEXT NOT NULL
);
-- Switch schema per request
SET search_path TO tenant_org123;
SELECT * FROM projects;
Pros:
- Natural isolation (no risk of forgetting tenant_id)
- Easy per-tenant backups and exports
- Moderate infrastructure cost
Cons:
- Schema migrations become complex (must run on every tenant schema)
- Connection pooling challenges
- Doesn't scale beyond ~1,000 tenants
Best for: B2B SaaS with dozens to hundreds of tenants needing stronger isolation.
3. Separate Databases
Each tenant gets their own database instance.
Pros:
- Complete isolation (security, performance, compliance)
- Easy data residency compliance
- Per-tenant scaling
Cons:
- Expensive (each tenant = database cost)
- Complex operations (migrations, monitoring, provisioning)
- Cross-tenant analytics requires aggregation
Best for: Enterprise SaaS with strict compliance requirements, or when tenants demand dedicated infrastructure.
Implementation: Row-Level Security (Recommended)
For most SaaS products, shared schema + PostgreSQL Row-Level Security (RLS) is the best approach. RLS enforces tenant isolation at the database level — even if your application code has a bug, the database won't return another tenant's data.
Setting Up RLS in PostgreSQL
-- 1. Enable RLS on your tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 2. Create a policy that restricts access to the current tenant
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- 3. Force RLS even for table owners (important!)
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
Setting the Tenant Context per Request
// Middleware: set tenant context on every request
async function setTenantContext(req, res, next) {
const tenantId = getTenantFromAuth(req); // Extract from JWT, session, etc.
await db.raw(`SET LOCAL app.current_tenant_id = '${tenantId}'`);
// SET LOCAL scopes to the current transaction
next();
}
With Supabase
Supabase has RLS built into its core. Every table can have RLS policies that reference the authenticated user.
-- Supabase RLS policy using auth.uid()
CREATE POLICY "Users can only see their org's projects"
ON projects
FOR SELECT
USING (
tenant_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid()
)
);
This is enforced automatically — your frontend can query projects directly and will only see rows for the user's organization.
With Prisma
Prisma doesn't natively support RLS, but you can implement tenant isolation:
// Option 1: Prisma middleware (adds tenant_id to every query)
prisma.$use(async (params, next) => {
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args.where = {
...params.args.where,
tenantId: currentTenantId,
};
}
return next(params);
});
// Option 2: Prisma Client Extensions (recommended in 2026)
const tenantPrisma = prisma.$extends({
query: {
$allOperations({ args, query }) {
args.where = { ...args.where, tenantId: currentTenantId };
return query(args);
},
},
});
With Drizzle
// Drizzle: filter by tenant on every query
const tenantProjects = await db
.select()
.from(projects)
.where(eq(projects.tenantId, currentTenantId));
For Drizzle, combine with PostgreSQL RLS as a safety net — apply tenant filtering in your application code AND enforce it at the database level.
Data Model
Core Tables
-- Organizations (tenants)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users (can belong to multiple orgs)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Memberships (user <-> tenant relationship)
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL DEFAULT 'member', -- owner, admin, member
UNIQUE(user_id, tenant_id)
);
-- Every business table gets 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 DEFAULT NOW()
);
-- Index for performance
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
Tenant Resolution
How to determine which tenant a request belongs to:
- From JWT/session: Most common. User authenticates → look up their active organization.
- From subdomain:
acme.yourapp.com→ resolveacmeto tenant ID. - From path:
yourapp.com/org/acme/dashboard→ resolve from URL. - From header:
X-Tenant-ID: org_123(for API access).
Organization Switching
Most B2B SaaS allows users to belong to multiple organizations. Implement an org switcher:
// Get user's organizations
const orgs = await db.membership.findMany({
where: { userId: currentUser.id },
include: { tenant: true },
});
// Switch active organization (store in session/cookie)
function switchOrg(tenantId: string) {
session.activeTenantId = tenantId;
}
Roles & Permissions
Basic RBAC for multi-tenant SaaS:
| Permission | Owner | Admin | Member | Viewer |
|---|---|---|---|---|
| Manage billing | ✅ | ❌ | ❌ | ❌ |
| Invite members | ✅ | ✅ | ❌ | ❌ |
| Create/edit projects | ✅ | ✅ | ✅ | ❌ |
| View projects | ✅ | ✅ | ✅ | ✅ |
| Delete organization | ✅ | ❌ | ❌ | ❌ |
Common Pitfalls
1. Forgetting tenant_id on a query
Without RLS, one missing WHERE tenant_id = ? exposes all tenants' data. Use RLS as a safety net, even if your application code also filters.
2. Not indexing tenant_id
Every table with tenant_id needs an index on it. Without indexes, queries slow down as data grows.
3. Global unique constraints
UNIQUE(email) prevents the same email across tenants. You probably want UNIQUE(tenant_id, email) instead.
4. Not testing with multiple tenants
Always seed your dev database with 2+ tenants. Test that tenant A can never see tenant B's data.
5. Tenant-unaware caching
If you cache query results, include tenant_id in cache keys. cache:projects is wrong. cache:org_123:projects is right.
FAQ
When should I NOT use shared schema?
When tenants have regulatory requirements for physical data isolation (some financial/healthcare regulations), when tenants need separate geographic data residency, or when tenant workloads vary dramatically (one tenant does 1000x the queries of others).
How do I handle data migrations?
For shared schema: standard migrations work. For schema-per-tenant: loop through all tenant schemas and apply each migration. For separate databases: deploy migrations to each database (use automation).
Should I use UUIDs or integers for tenant_id?
UUIDs. They're globally unique (safe for multi-database setups), not guessable (security), and don't leak information about tenant count.
How do I handle tenant deletion?
Soft delete the tenant record, cascade to disable access, then batch-delete data asynchronously. Keep data for a grace period (30 days) before permanent deletion.
The Bottom Line
For most SaaS products:
- Use shared schema with
tenant_idon every table - Enable PostgreSQL RLS as a safety net
- Add
tenant_idindexes on every table - Resolve tenant from auth (JWT/session)
- Test with multiple tenants from day one
This approach handles 90% of B2B SaaS needs, scales to millions of tenants, and costs nothing extra in infrastructure.