← Back to articles

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:

  1. From JWT/session: Most common. User authenticates → look up their active organization.
  2. From subdomain: acme.yourapp.com → resolve acme to tenant ID.
  3. From path: yourapp.com/org/acme/dashboard → resolve from URL.
  4. 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:

PermissionOwnerAdminMemberViewer
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:

  1. Use shared schema with tenant_id on every table
  2. Enable PostgreSQL RLS as a safety net
  3. Add tenant_id indexes on every table
  4. Resolve tenant from auth (JWT/session)
  5. 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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.