Multi-tenancy
Three strategies, all packaged as plugins. The right one isn't the one that's easiest to wire — it's the one whose isolation guarantees match what your customers actually expect.
Pick the strategy first
| Strategy | Isolation | Operational cost | Use when |
|---|---|---|---|
| **Row-level** | Weakest — one bad WHERE clause leaks across tenants. | Lowest — single schema, single pool. | B2C apps, free tiers, anything where the customer never asks about isolation. |
| **Schema-based** | Strong — Postgres search_path enforces it. | Medium — one schema per tenant; migrations fan out. | B2B apps where customers ask about data separation but accept shared infra. |
| **Database-based** | Strongest — separate databases. | Highest — one connection pool per tenant; per-tenant backups. | Regulated workloads, BAA-bound healthcare, audited financials, sovereignty contracts. |
Row-level — rowLevelTenancy
Every tenant lives in the same tables, distinguished by a tenant_id column. The plugin injects tenant_id = $N into every WHERE clause and appends it to every INSERT.
import { rowLevelTenancy } from 'zanith';import { AsyncLocalStorage } from 'node:async_hooks'; const requestStore = new AsyncLocalStorage<{ tenantId: string }>(); await createZanith({ adapter, models, plugins: [ rowLevelTenancy({ tenantColumn: 'tenant_id', tables: ['users', 'orders'], // omit to scope every model getTenantId: () => requestStore.getStore()!.tenantId, }), ],});RowLevelTenancyOptions
| Option | Default | Effect |
|---|---|---|
tenantColumn | 'tenant_id' | Column the plugin filters on / writes. |
tables | all tables (except _zanith_*) | Restrict scoping to a list — useful when shared lookup tables shouldn't be tenant-scoped. |
getTenantId | required | Called per query. Sync or async. Reads your AsyncLocalStorage. |
Schema-based — schemaBasedTenancy
Each tenant gets its own Postgres schema. The plugin issues SET LOCAL search_path = tenant_<id>, public ahead of every query so unqualified table references hit the right tenant.
import { schemaBasedTenancy } from 'zanith'; plugins: [ schemaBasedTenancy({ getTenantSchema: () => `tenant_${requestStore.getStore()!.tenantId}`, fallbackSchemas: ['public'], }),],| Option | Default | Effect |
|---|---|---|
getTenantSchema | required | Resolves the active tenant's schema name. |
fallbackSchemas | ['public'] | Schemas always added after the tenant's — for shared lookup tables, extension schemas, etc. |
Database-based — databaseBasedTenancy
Strongest isolation. Each tenant has its own database. Unlike the other two, this strategy doesn't fit cleanly inside onQuery — adapter swapping has to happen at the createZanith boundary. The function returns a DatabaseTenantRouter you wire into your request handler.
import { databaseBasedTenancy, PgAdapter, createZanith } from 'zanith'; const router = databaseBasedTenancy({ adapterFor: async (tenantId) => new PgAdapter({ connectionString: connectionStringFor(tenantId) }), getTenantId: () => requestStore.getStore()!.tenantId, maxAdapters: 64, // LRU cap on cached adapters}); // Per request:const adapter = await router.routeCurrent();const db = await createZanith({ adapter, models });DatabaseTenantRouter
| Method | Purpose |
|---|---|
getAdapterFor(id) | Build / fetch the adapter for a specific tenant. |
routeCurrent() | Resolve via getTenantId, then route. |
evict(id) | Drop one tenant's adapter and disconnect. |
shutdown() | Disconnect every cached adapter — call on process exit. |
The orgScoped mixin
The schema-side helper for row-level tenancy is m.orgScoped() — see Mixins. It adds the FK column and an index, so the row-level plugin always has a real column to filter on.
const Order = defineModel((m) => ({ name: 'Order', table: 'orders', fields: { ...m.id(), ...m.timestamps(), ...m.orgScoped(), // organizationId: uuid (indexed) total: m.decimal(), },}));