Multi-tenancy is where many SaaS products make a decision they later regret. The architecture you choose early shapes your data model, your performance characteristics, and how hard it is to add enterprise customers who demand data isolation. Here are the three patterns, honestly evaluated.
Pattern 1: Separate Databases per Tenant
Each tenant gets their own Postgres database. Maximum isolation, simplest backup-per-tenant story, easy to migrate one customer's data independently. The cost: operational complexity scales with customer count. At 10 tenants this is manageable. At 1,000 it becomes a full-time job.
- Use when: compliance requirements mandate data isolation (HIPAA, banking)
- Use when: you have a small number of high-value enterprise customers
- Avoid when: you expect high volume of small customers
Pattern 2: Separate Schemas per Tenant
One Postgres instance, one schema per tenant. You get decent isolation without full database overhead. The schema creation is fast and Postgres handles hundreds of schemas comfortably. Migration tooling gets complicated — you need to run schema changes across every tenant schema when you ship a new version.
Pattern 3: Row-Level Isolation (What I Use)
One database, one schema, every table has a tenant_id column. All queries are scoped to the tenant. This is the simplest architecture to build and the most cost-efficient to run. The risk: a missing WHERE clause could expose one tenant's data to another.
// db/schema.ts — every table gets tenant_id
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(),
})
// Always scope to tenant — never query without it
export async function getProjects(tenantId: string) {
return db.select().from(projects).where(eq(projects.tenantId, tenantId))
}Tenant Resolution in Next.js Middleware
Subdomain-based tenant resolution is the standard pattern. Each customer gets their own subdomain (acme.yourapp.com) and middleware resolves the tenant from the hostname.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(req: NextRequest) {
const hostname = req.headers.get('host') ?? ''
const subdomain = hostname.split('.')[0]
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
const tenant = await getTenantBySubdomain(subdomain)
if (!tenant) {
return NextResponse.redirect(new URL('/404', req.url))
}
const res = NextResponse.next()
res.headers.set('x-tenant-id', tenant.id)
return res
}
return NextResponse.next()
}Which Pattern Should You Choose?
For most early-stage SaaS: row-level isolation. Build it cleanly with a strict convention that every query includes tenantId. Once you have the revenue to justify the operational cost, you can migrate high-value enterprise customers to their own schema or database later. Start simple and evolve.
The most expensive multi-tenancy mistake is over-engineering it upfront. Build for your first 100 customers, not your hypothetical 10,000.