Introduction
Fail-closed, TypeScript-first multi-tenancy for Node.js - one tenant-isolation contract for the framework and ORM you already use.
TenancyJS gives every framework and ORM the same tenant-context and isolation contract - without
replacing the framework or ORM you already use. Tenant identity follows the async execution scope, so
your queries stay scoped without threading a tenantId through every call.
And the guarantee that matters:
Any tenant-aware data access without a valid tenant context throws - it never returns another tenant's data.
How it fits together
You compose three independent choices - they don't need to know about each other, so mix and match:
- A strategy - how tenants are kept apart: row-level, schema-per-tenant, or database-per-tenant. Pick one per app.
- An adapter - your ORM (Prisma, Knex, Lucid, TypeORM, Sequelize, Drizzle, Mongoose). It enforces the strategy on every query.
- An integration - your framework (Express, Next.js, NestJS, AdonisJS). It sets the current tenant for each request.
At runtime it's one loop: the integration resolves the tenant and opens a scope → your code queries through the adapter's scoped client → the strategy isolates every query → the scope tears down on the way out. Miss the scope and access throws instead of leaking. That's the whole model.
The rules - what to do, what to avoid
The guarantee only holds for access that goes through the scoped client. So there are a few rules - follow these and you can't leak:
- ✅ Do run every tenant query through the client you get inside
tenancy.run(...)(or your models, on Lucid/AdonisJS), within an open tenant scope. - ✅ Do let it throw. A
TenantContextErroroutside a scope is the safety net doing its job - don't catch-and-ignore it. - ❌ Don't reach for the native client, model, connection, or collection inside a scope - that sidesteps the facade, and on MySQL/MongoDB (no database backstop) it's a direct cross-tenant leak.
- ❌ Don't expect raw SQL or nested/relational writes from the facade - they're rejected unless
you're in a database-per-tenant scope, where
unrestricted()hands you the real client safely.
The full, honest list is on the Limitations page - read it before you build.
New here? Start with these
Installation
Scaffold your stack with the CLI, or install the packages by hand.
Quickstart
Wire tenant context into an app and run your first scoped query.
Configuration
Define a tenancy.config.ts runtime the CLI and your app share.
Capability matrix
Exactly which adapter × strategy combinations are tested-supported.
The core idea
The dangerous bug in multi-tenancy is the one that doesn't throw. Forget a WHERE tenant_id = ? and
nothing breaks in the demo - you just quietly returned one customer's data to another. TenancyJS makes
the safe path the default: no valid tenant context, no data.
Tenant context
How identity flows through your app on AsyncLocalStorage - and why it fails closed.
Security model
What TenancyJS guarantees, what it doesn't, and where the boundaries are.
Limitations
What the scoped facade rejects - no raw queries, no nested reads/writes - and why.
Choose an isolation strategy
Three strategies, one contract. Pick per app; the same tenant scope drives all three.
On MySQL or MongoDB? Schema-per-tenant is not a distinct strategy there, but row-level and database-per-tenant are available. The capability matrix breaks down the enforcement strength.
Single database (row-level)
Shared tables + tenant_id, enforced by forced Postgres RLS or query-scoping.
Schema per tenant
One Postgres schema per tenant via transaction-local search_path.
Database per tenant
A separate database per tenant, cache-routed for hard isolation.
Wire up your stack
Adapters
Enforce isolation inside your ORM - Prisma, Knex, Lucid, TypeORM, Sequelize, Drizzle, Mongoose.
Integrations
Scope every request automatically - Express, Next.js, NestJS, AdonisJS.
Operational CLI
Inspect, create, migrate, and run against your live tenants.
Tenant identity is not authorization. Your app still owns auth. TenancyJS owns exactly one thing: that one tenant's data never becomes another's.