zanith

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

StrategyIsolationOperational costUse 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.

TS
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

OptionDefaultEffect
tenantColumn'tenant_id'Column the plugin filters on / writes.
tablesall tables (except _zanith_*)Restrict scoping to a list — useful when shared lookup tables shouldn't be tenant-scoped.
getTenantIdrequiredCalled 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.

TS
import { schemaBasedTenancy } from 'zanith';
 
plugins: [
schemaBasedTenancy({
getTenantSchema: () => `tenant_${requestStore.getStore()!.tenantId}`,
fallbackSchemas: ['public'],
}),
],
OptionDefaultEffect
getTenantSchemarequiredResolves 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.

TS
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

MethodPurpose
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.

TS
const Order = defineModel((m) => ({
name: 'Order', table: 'orders',
fields: {
...m.id(),
...m.timestamps(),
...m.orgScoped(), // organizationId: uuid (indexed)
total: m.decimal(),
},
}));