How to build tenant-isolated SaaS APIs with Nerva. Generate a project with the scaffolding included:
./scripts/setup-project.sh my-saas --node --multi-tenant # any platform flag worksThis adds api/src/tenancy/ (tenant registry schema, resolution middleware,
scoped query helpers for both isolation strategies), an RLS policy template at
api/src/db/rls-policies.sql, and tenant-isolation tests at
api/tests/tenancy.test.ts.
Nerva supports both mainstream PostgreSQL tenancy models. Pick one per
deployment with TENANCY_STRATEGY (row is the default); the resolution
middleware and tenants registry are shared, only the data layer differs.
All tenants share the same tables. Every tenant-scoped table carries a
tenant_id foreign key, every query filters on it, and PostgreSQL Row Level
Security enforces the boundary inside the database.
┌─────────────────────────────────────┐
│ public │
│ tenants (id, name, slug, plan) │
│ projects (id, tenant_id, ...) │ ← one table, all tenants,
│ orders (id, tenant_id, ...) │ RLS + WHERE tenant_id = $1
└─────────────────────────────────────┘
Each tenant gets its own PostgreSQL schema containing a full copy of the
resource tables. Queries run with search_path pointed at the tenant's
schema; tables need no tenant_id column.
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ public │ │ tenant_acme │ │ tenant_globex │
│ tenants │ │ projects │ │ projects │
│ (registry) │ │ orders │ │ orders │
└────────────────┘ └────────────────┘ └────────────────┘
| Dimension | Row-based (tenant_id + RLS) |
Schema-based (schema per tenant) |
|---|---|---|
| Isolation strength | Logical — enforced by RLS policies and query filters | Structural — data lives in physically separate namespaces |
| Tenant count | Scales to millions of tenants | Practical up to low thousands; catalogs, backups, and migrations degrade beyond that |
| Tenant onboarding | One INSERT INTO tenants |
CREATE SCHEMA + run migrations for the new schema |
| Migrations | One migration run for everyone | One run per tenant schema; slow rollouts, risk of drift if a run fails midway |
| Cross-tenant queries (admin dashboards, aggregate analytics) | Trivial — omit the filter with a privileged role | Painful — UNION across schemas or ETL into a warehouse |
| Per-tenant backup / restore / export | Row-filtered dumps (fiddly) | pg_dump --schema=tenant_x (natural) |
| Noisy-neighbor blast radius | Shared tables and indexes; one tenant's bloat affects all | Separate indexes per tenant; still shared CPU/IO on one server |
| Per-tenant customization (extra columns, retention) | Hard — schema is shared | Easy — schemas can diverge (at the cost of drift) |
| Connection pooling | Pool-friendly: tenant context is per-transaction (SET LOCAL) |
Pool-friendly with SET LOCAL search_path, but plan caches grow per schema |
| Compliance optics | "Logically isolated" — fine for most SOC 2 / GDPR setups | "Physically separated" — easier story for strict enterprise/regulated buyers |
| Operational complexity | Low | Medium-high (migration runner, schema lifecycle, monitoring per schema) |
Default to row-based. It scales further, keeps migrations and analytics simple, and RLS gives you database-enforced isolation without physical separation. Choose schema-based when you have a bounded number of high-value tenants that demand stronger separation, per-tenant restore/export, or per-tenant schema divergence — or as a stepping stone to database-per-tenant for your largest customers. (Full database-per-tenant is out of scope for this template; it is the same trade-off pushed one level up.)
tenantResolver (in src/tenancy/middleware.ts) maps each request to a
tenant and stores it on the Hono context. Three sources are supported, tried
in priority order (default ['jwt', 'subdomain', 'header']):
| Source | Example | Trust |
|---|---|---|
| JWT claim | tenant_id claim in the verified token |
Signed — cannot be spoofed. Default first. |
| Subdomain | acme.api.example.com → slug acme |
Controlled by DNS/routing. Good for public APIs. |
X-Tenant-ID header |
X-Tenant-ID: <uuid> |
Spoofable. Only trust behind a gateway that strips it from external traffic, or verify membership in lookupTenant. |
import { eq } from 'drizzle-orm';
import { tenantResolver, getTenant } from './tenancy/middleware';
import { tenants, type Tenant } from './tenancy/schema';
import { loadTenancyConfig } from './tenancy/config';
const tenancy = loadTenancyConfig();
app.use(
'/api/*',
tenantResolver({
baseDomain: tenancy.baseDomain,
headerName: tenancy.headerName,
jwtClaim: tenancy.jwtClaim,
lookupTenant: async (ref) =>
db.query.tenants.findFirst({
where: ref.source === 'subdomain'
? eq(tenants.slug, ref.value)
: eq(tenants.id, ref.value),
}),
}),
);Unresolvable requests get 400 TENANT_UNRESOLVED; unknown tenants get 404 TENANT_NOT_FOUND (404 rather than 403, so probes cannot enumerate tenants).
When you mount hono/jwt, mount it before the resolver so the verified
payload is available, and put per-tenant caching inside lookupTenant — it
runs on every request.
src/tenancy/schema.ts defines the tenants registry and a
tenantScopedColumns() helper. Every resource table spreads it and indexes
the column:
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
...tenantScopedColumns(), // tenant_id uuid NOT NULL REFERENCES tenants(id)
total: integer('total').notNull(),
}, (table) => [index('orders_tenant_id_idx').on(table.tenantId)]);The generated drizzle.config.ts already includes src/tenancy/schema.ts,
so pnpm drizzle-kit generate picks these tables up.
Isolation is enforced twice, deliberately:
-
Application layer —
createTenantDbreturns a facade whose every method injects the tenant predicate, so handlers cannot forget it:app.get('/api/projects', async (c) => { const tenant = getTenant<Tenant>(c); const tdb = createTenantDb(db, tenant.id); return c.json(await tdb.findMany(projects)); // WHERE tenant_id = $1 }); // findFirst / insert / update / delete are scoped the same way: await tdb.insert(projects, { name: 'Q3 launch' }); // tenant_id injected await tdb.update(projects, { name: 'Q4' }, eq(projects.id, id));
For hand-written queries, compose
withTenantFilter(table, tenantId, ...)into thewhereclause — same pattern as the soft-delete helpers. -
Database layer (RLS) —
withTenantbindsapp.tenant_idfor one transaction; the policies compare each row against it:const rows = await withTenant(db, tenant.id, (tx) => tx.select().from(projects).where(withTenantFilter(projects, tenant.id)), );
The application filter gives correct results and index-friendly plans; RLS is the backstop that turns a forgotten filter into zero rows instead of a cross-tenant data leak.
The policy template is copied to api/src/db/rls-policies.sql. Ship it as a
custom migration:
pnpm drizzle-kit generate --custom --name enable-rls
# paste the SQL (one block per tenant-scoped table) into the new migration
pnpm drizzle-kit migrateEach block enables + forces RLS and adds one FOR ALL policy whose USING
clause hides other tenants' rows and whose WITH CHECK clause blocks writing
them (including UPDATE ... SET tenant_id = <other>). The policy reads
current_setting('app.tenant_id', true) and fails closed: no tenant
context, no rows.
Two things bypass RLS and must be avoided for the API's database user:
- Superusers and
BYPASSRLSroles ignore policies entirely. The defaultpostgresuser in most Docker images is a superuser — create a dedicated application role (the template's header comment has the statements). - Table owners ignore policies unless
FORCE ROW LEVEL SECURITYis set — the template sets it on every table.
Onboarding creates the schema, then applies migrations to it:
import { createTenantSchema, withTenantSchema } from './tenancy/schema-scope';
await db.insert(tenants).values({ name: 'Acme', slug: 'acme' });
await createTenantSchema(db, 'acme'); // CREATE SCHEMA tenant_acmeQueries run inside withTenantSchema, which points search_path at the
tenant's schema with SET LOCAL (transaction-scoped, so pooled connections
never leak a tenant):
const rows = await withTenantSchema(db, tenant.slug, (tx) =>
tx.select().from(projects), // resolves to tenant_acme.projects
);Slugs are validated ([a-z0-9_-]{1,48}) before being turned into
identifiers; anything else throws rather than getting escaped into DDL.
Migrations must run once per schema. A minimal runner using Drizzle's programmatic migrator (Node targets):
// scripts/migrate-tenants.ts — run with: pnpm tsx scripts/migrate-tenants.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { tenants } from '../src/tenancy/schema';
import { tenantSchemaName } from '../src/tenancy/schema-scope';
const client = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(client);
for (const tenant of await db.select().from(tenants)) {
const schema = tenantSchemaName(tenant.slug);
await client.unsafe(`set search_path to "${schema}", public`);
await migrate(db, {
migrationsFolder: './src/db/migrations',
migrationsSchema: schema, // per-schema migration journal
});
console.log(`migrated ${schema}`);
}
await client.end();Run it in CI/CD after deploying, and at onboarding for the new tenant's
schema. Keep resource tables out of public under this strategy:
search_path falls through to public, so a stray shared copy of a table
would silently serve requests for any tenant whose schema is missing it.
SET LOCAL+ poolers. BothwithTenantandwithTenantSchemabind tenant context withset_config(..., true)inside a transaction, which is safe with connection pooling (including transaction-mode PgBouncer). Never use session-levelSETfor tenant context on pooled connections.- The
tenantsregistry is not tenant-scoped. The resolver must read it before any tenant context exists; don't add RLS to it (or give it only a permissive read policy). - Admin/cross-tenant paths must be explicit. Analytics jobs or support tooling that legitimately crosses tenants should use a separate privileged role and never share code paths with request handlers.
api/tests/tenancy.test.ts runs in two layers:
- Always on (no database needed): resolution from subdomain/header/JWT,
precedence and rejection cases, and SQL-level assertions that every
createTenantDbmethod and bothSET LOCALhelpers emit tenant-scoped statements. - Live suite (opt-in): proves isolation against a real PostgreSQL — RLS
visibility per tenant, fail-closed with no context,
WITH CHECKblocking cross-tenant writes, andsearch_pathschema isolation — running as a dedicated non-superuser role, the same shape production should use.
docker compose up -d
TENANCY_TEST_DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db pnpm testTreat the live suite as a required CI gate for any real multi-tenant product: run it against the CI database service so a policy regression fails the build instead of leaking a tenant's data.