← Back to articles

How to Build a Multi-Tenant SaaS App (2026)

Multi-tenancy lets multiple customers (tenants) share one application while keeping their data isolated. Every SaaS with team/organization features is multi-tenant. Here's how to implement it.

The Three Patterns

1. Shared Database + Tenant Column (Recommended)

All tenants share one database. Every table has a tenant_id column.

CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

Pros: Simplest. Cheapest. Easy to maintain. Cons: Must always filter by tenant_id. Data leak risk if you forget.

2. Schema-Per-Tenant (PostgreSQL)

Each tenant gets their own PostgreSQL schema within one database.

CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);

CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_globex.projects (...);

Pros: Better isolation. No tenant_id on every query. Cons: Migration complexity. Schema count limits.

3. Database-Per-Tenant

Each tenant gets their own database.

tenant-acme.db
tenant-globex.db
tenant-initech.db

Pros: Maximum isolation. Easy to delete tenant data. Cons: Most expensive. Connection management complexity. Migrations across all databases.

Recommended: Shared Database + Tenant Column

For 95% of SaaS apps, shared database with tenant_id is the right choice. Here's the complete implementation.

Database Schema (Drizzle)

export const tenants = pgTable('tenants', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  plan: text('plan').default('free').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
})

export const members = pgTable('members', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
  userId: text('user_id').notNull(), // Clerk user ID
  role: text('role').default('member').notNull(), // admin, member
})

export const projects = pgTable('projects', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
})

Tenant Context Middleware

// lib/tenant.ts
import { auth } from '@clerk/nextjs/server'

export async function getTenant() {
  const { userId, orgId } = auth()
  if (!userId) throw new Error('Unauthorized')

  // Clerk orgId maps to your tenant
  const tenant = await db.query.tenants.findFirst({
    where: eq(tenants.clerkOrgId, orgId),
  })

  if (!tenant) throw new Error('Tenant not found')
  return tenant
}

Always Filter by Tenant

// ✅ Correct: always include tenantId
export async function getProjects() {
  const tenant = await getTenant()
  return db.select()
    .from(projects)
    .where(eq(projects.tenantId, tenant.id))
}

// ❌ WRONG: missing tenant filter = data leak
export async function getProjects() {
  return db.select().from(projects) // Returns ALL tenants' data!
}

Row-Level Security (Extra Safety)

-- PostgreSQL RLS as a safety net
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Set the tenant context at the start of each request:

await db.execute(sql`SET LOCAL app.tenant_id = ${tenant.id}`)

Even if you forget a WHERE clause, RLS prevents data leaks.

Clerk Organizations Integration

// When user creates an org in Clerk → create tenant
// Clerk webhook handler
if (event.type === 'organization.created') {
  await db.insert(tenants).values({
    name: event.data.name,
    slug: event.data.slug,
    clerkOrgId: event.data.id,
  })
}

// When member joins org → add to members table
if (event.type === 'organizationMembership.created') {
  await db.insert(members).values({
    tenantId: tenant.id,
    userId: event.data.public_user_data.user_id,
    role: event.data.role,
  })
}

Common Patterns

Tenant-Scoped API Routes

// Middleware that injects tenant into every request
export async function middleware(request: NextRequest) {
  const tenant = await getTenantFromRequest(request)
  request.headers.set('x-tenant-id', tenant.id)
}

Tenant Subdomains

acme.yourapp.com → tenant: acme
globex.yourapp.com → tenant: globex
function getTenantFromHostname(hostname: string) {
  const subdomain = hostname.split('.')[0]
  return db.query.tenants.findFirst({
    where: eq(tenants.slug, subdomain),
  })
}

Plan-Based Feature Gating

export async function canUseFeature(feature: string) {
  const tenant = await getTenant()
  const limits = PLAN_LIMITS[tenant.plan]
  return limits.features.includes(feature)
}

FAQ

Which pattern should I use?

Shared database + tenant_id for 95% of apps. Database-per-tenant only for strict compliance requirements or if tenants need independent backups.

How do I prevent data leaks between tenants?

Always filter by tenant_id. Add PostgreSQL RLS as a safety net. Write tests that verify tenant isolation.

Should I use Clerk Organizations?

Yes, if using Clerk. It handles team creation, invitations, roles, and the org switcher UI. Map Clerk org IDs to your tenant table.

How do I handle migrations with database-per-tenant?

Script that runs migrations across all databases. Tools like Turso support this pattern well with their multi-database feature.

Bottom Line

Use shared database + tenant_id column. It's the simplest, cheapest, and most maintainable pattern. Add PostgreSQL RLS for safety. Use Clerk Organizations for team management. Test tenant isolation in your CI pipeline. Multi-tenancy isn't as hard as it sounds — it's just adding WHERE tenant_id = ? to every query.

Get AI tool guides in your inbox

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