← Back to articles

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

Multi-tenancy means one application serves multiple customers (tenants), each seeing only their own data. It's the foundation of B2B SaaS. Here's how to architect it properly in 2026.

Multi-Tenancy Models

1. Shared Database, Shared Schema (Most Common)

All tenants share one database. A tenant_id column on every table isolates data.

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

-- Every query includes tenant_id
SELECT * FROM projects WHERE tenant_id = 'abc-123';

Pros: Cheapest, simplest, easiest to maintain Cons: Data isolation relies on application logic. One bad query leaks data.

Best for: Most SaaS apps, especially early stage.

2. Shared Database, Separate Schemas

Each tenant gets their own database schema (Postgres schemas).

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

SET search_path TO tenant_abc123;
SELECT * FROM projects; -- Only sees tenant's data

Pros: Stronger isolation. Easier per-tenant migrations. Cons: More complex. Schema management at scale is painful.

Best for: Regulated industries needing stronger isolation.

3. Separate Databases Per Tenant

Each tenant gets their own database instance.

Pros: Maximum isolation. Easy to comply with data residency requirements. Cons: Expensive. Complex to manage. Can't easily query across tenants.

Best for: Enterprise customers requiring dedicated infrastructure.

The Practical Architecture (2026)

For most SaaS apps, use shared database with tenant_id and add row-level security:

Stack

  • Framework: Next.js or Hono
  • Database: Neon or Supabase (Postgres)
  • Auth: Clerk (built-in organization/tenant support)
  • ORM: Drizzle

Step 1: Database Schema

-- Tenants (organizations)
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 belong to tenants
CREATE TABLE tenant_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  user_id TEXT NOT NULL, -- From auth provider (Clerk user ID)
  role TEXT DEFAULT 'member',
  UNIQUE(tenant_id, user_id)
);

-- All data tables include 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 fast tenant queries
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

Step 2: Row-Level Security (Postgres)

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:

async function withTenant(tenantId: string, fn: () => Promise<any>) {
  await db.execute(sql`SET LOCAL app.tenant_id = ${tenantId}`)
  return fn()
}

This is your safety net. Even if application code forgets to filter by tenant_id, RLS prevents data leaks.

Step 3: Auth with Clerk Organizations

Clerk has built-in organization (tenant) support:

import { auth } from '@clerk/nextjs/server'

export async function GET() {
  const { orgId } = auth()
  
  if (!orgId) {
    return Response.json({ error: 'No organization selected' }, { status: 403 })
  }
  
  // orgId is the tenant_id
  const projects = await db.query.projects.findMany({
    where: eq(projects.tenantId, orgId),
  })
  
  return Response.json(projects)
}

Step 4: Middleware for Tenant Context

// middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware((auth, req) => {
  const { orgId } = auth()
  
  // Ensure API routes always have a tenant context
  if (req.nextUrl.pathname.startsWith('/api/') && !orgId) {
    return Response.json({ error: 'Organization required' }, { status: 403 })
  }
})

Step 5: Drizzle Schema with Tenant ID

import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'

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

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

Tenant-Aware Patterns

Data Access Layer

Never query without tenant context:

class TenantRepository {
  constructor(private tenantId: string) {}

  async getProjects() {
    return db.query.projects.findMany({
      where: eq(projects.tenantId, this.tenantId),
    })
  }

  async createProject(name: string) {
    return db.insert(projects).values({
      tenantId: this.tenantId,
      name,
    })
  }
}

Subdomain-Based Routing

acme.yourapp.com → tenant: acme
bigcorp.yourapp.com → tenant: bigcorp
function getTenantFromHost(host: string): string | null {
  const subdomain = host.split('.')[0]
  if (subdomain === 'www' || subdomain === 'app') return null
  return subdomain
}

API Key Per Tenant

// API keys include tenant context
const apiKey = await db.query.apiKeys.findFirst({
  where: eq(apiKeys.key, request.headers.get('x-api-key')),
})

const tenantId = apiKey.tenantId

Billing Per Tenant

Stripe Integration

Map Stripe customers to tenants:

// When tenant signs up for paid plan
const customer = await stripe.customers.create({
  metadata: { tenantId: tenant.id },
})

const subscription = await stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: 'price_pro_monthly' }],
})

await db.update(tenants)
  .set({ stripeCustomerId: customer.id, plan: 'pro' })
  .where(eq(tenants.id, tenant.id))

Usage-Based Limits

async function checkLimit(tenantId: string, feature: string) {
  const tenant = await getTenant(tenantId)
  const limits = PLAN_LIMITS[tenant.plan]
  const usage = await getUsage(tenantId, feature)
  
  if (usage >= limits[feature]) {
    throw new Error(`${feature} limit reached. Upgrade to increase.`)
  }
}

Common Mistakes

1. Forgetting tenant_id on a Query

One missing WHERE clause leaks all tenants' data. Mitigate with:

  • Row-level security (Postgres RLS)
  • ORM middleware that auto-adds tenant filter
  • Code review checklist

2. No Index on tenant_id

Every table with tenant_id needs an index. Without it, queries scan all rows.

3. Global Uniqueness Constraints

Email uniqueness should be per-tenant, not global:

UNIQUE(tenant_id, email)  -- ✅ Correct
UNIQUE(email)             -- ❌ Wrong (blocks same email in different tenants)

4. Hardcoded Tenant in Background Jobs

Background jobs must include tenant context:

queue.add('sendReport', { tenantId: 'abc-123', reportId: '456' })

FAQ

Which multi-tenancy model should I use?

Shared database with tenant_id for 95% of SaaS apps. Separate databases only if enterprise customers contractually require it.

How do I handle data migrations?

With shared database, migrations apply to all tenants at once. With separate schemas/databases, you need a migration runner that iterates through tenants.

What about data residency (GDPR)?

For EU data residency, you can use database-level isolation for those tenants or route to an EU-region database. Neon and Supabase support multiple regions.

How many tenants can one database handle?

Postgres easily handles thousands of tenants in one database with proper indexing. Most SaaS apps never need to shard.

Bottom Line

Start with the simplest model: shared Postgres database with tenant_id columns, row-level security, and Clerk for auth. This handles everything from MVP to thousands of customers. Only add complexity (separate schemas, separate databases) when enterprise customers require it.

Get AI tool guides in your inbox

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