@follosoft/drizzle-multitenancy
v0.2.1
Published
Multi-tenancy for Drizzle ORM — inspired by Spatie's laravel-multitenancy, built for serverless edge functions.
Maintainers
Readme
@follosoft/drizzle-multitenancy
Multi-tenancy for Drizzle ORM — inspired by Spatie's laravel-multitenancy, built for TypeScript + serverless edge functions.
Provider-agnostic: works with any Postgres-compatible Drizzle driver. Ships with a built-in Neon provider, ready to add Supabase, PlanetScale, or plain node-postgres.
Installation
npm install @follosoft/drizzle-multitenancyFor Neon (built-in provider):
npm install drizzle-orm @neondatabase/serverlessQuick Start
import {
defineConfig, withTenant, needsTenant,
createHeaderFinder, createSubdomainFinder,
createSwitchDatabaseTask, createSwitchConfigTask,
neonClientFactory,
} from '@follosoft/drizzle-multitenancy'
const tenancy = defineConfig({
landlordDatabaseUrl: process.env.DATABASE_URL!,
databaseStrategy: 'separate-db',
createDatabaseClient: neonClientFactory(),
tenantFinders: [
createHeaderFinder(),
createSubdomainFinder({ baseDomain: 'example.com' }),
],
switchTenantTasks: [
createSwitchDatabaseTask(neonClientFactory()),
createSwitchConfigTask(),
],
})
export default withTenant(tenancy, async (req, ctx) => {
needsTenant(ctx) // throws if no tenant resolved
const data = await ctx.db.select().from(users).limit(10)
return Response.json({ tenant: ctx.tenant.name, data })
})Database Providers
The package is provider-agnostic. You supply a DatabaseClientFactory — a function that takes a connection URL and returns a Drizzle database client.
Neon (built-in)
import { neonClientFactory } from '@follosoft/drizzle-multitenancy'
defineConfig({
createDatabaseClient: neonClientFactory(),
// ...
})Custom provider
Implement your own factory to use any Drizzle-compatible Postgres driver:
import { drizzle } from 'drizzle-orm/node-postgres'
import pg from 'pg'
import type { DatabaseClientFactory } from '@follosoft/drizzle-multitenancy'
const nodePgFactory: DatabaseClientFactory = (url) => {
const pool = new pg.Pool({ connectionString: url })
return drizzle(pool)
}
defineConfig({
createDatabaseClient: nodePgFactory,
// ...
})This makes it straightforward to add support for Supabase, PlanetScale, Turso, or any future Drizzle driver.
Concepts
Tenant
A tenant represents an organization/customer with its own database (or scoped data). The built-in tenants Drizzle table schema is provided for the landlord database:
import { tenants } from '@follosoft/drizzle-multitenancy'Database Strategies
Separate DB (createSwitchDatabaseTask) — Each tenant gets its own database. A new Drizzle client is created per request using the configured provider.
Shared DB (createTenantScopeTask) — Single database, rows scoped by tenant_id column. Use the scopedWhere() helper:
import { createTenantScopeTask, scopedWhere } from '@follosoft/drizzle-multitenancy'
// In your handler:
const rows = await ctx.db
.select()
.from(orders)
.where(scopedWhere(ctx, orders.tenantId))Tenant Finders
Finders resolve the current tenant from the incoming request:
| Finder | Resolves from |
|--------|--------------|
| createHeaderFinder() | X-Tenant-ID header (configurable) |
| createSubdomainFinder({ baseDomain }) | Subdomain of the hostname |
| createJwtFinder() | JWT tenant_id claim (configurable) |
| createCompositeFinder(finders) | First match from a chain of finders |
Switch Tasks
Tasks run when a tenant becomes current (and in reverse when forgotten):
| Task | Purpose |
|------|---------|
| createSwitchDatabaseTask(factory) | Creates a new Drizzle client for the tenant's DB |
| createSwitchConfigTask() | Copies tenant config/locale into context |
| createTenantScopeTask() | Sets tenantId metadata for shared-DB scoping |
You can create custom tasks by implementing the SwitchTenantTask interface.
Middleware
withTenant(tenancy, handler)— HOF that wraps a request handler with automatic tenant resolution and cleanup.needsTenant(ctx)— Type guard that asserts a tenant exists on the context, narrowing the type toResolvedTenantContext.
Collection Utilities
Iterate over multiple tenants:
import { eachCurrent, mapCurrent, filterCurrent } from '@follosoft/drizzle-multitenancy'
// Run a migration for every tenant
await eachCurrent(landlordDb, tasks, async (ctx) => {
await ctx.db.execute(sql`ALTER TABLE ...`)
})
// Collect stats from each tenant
const stats = await mapCurrent(landlordDb, tasks, async (ctx) => {
const [row] = await ctx.db.select({ count: sql`count(*)` }).from(users)
return { tenant: ctx.tenant!.name, users: row.count }
})Job Serialization
Preserve tenant context across async job boundaries:
import { wrapDispatcher, withTenantJob } from '@follosoft/drizzle-multitenancy'
// Producer: inject tenant into job payload
const dispatch = wrapDispatcher(ctx.tenant, enqueueJob)
dispatch({ orderId: '123' }) // payload includes tenantId + tenantDatabaseUrl
// Consumer: restore tenant context in job handler
export const handler = withTenantJob(landlordDb, tasks, async (payload, ctx) => {
// ctx.tenant and ctx.db are set
await ctx.db.insert(notifications).values({ ... })
})Landlord Execute
Temporarily drop tenant context to run queries against the landlord DB:
import { landlordExecute } from '@follosoft/drizzle-multitenancy'
const allTenants = await landlordExecute(ctx, tasks, async (db) => {
return db.select().from(tenants)
})API Reference
Config
defineConfig(config: TenancyConfig): Tenancy
Types
Tenant— Tenant recordTenantContext— Request-scoped context (tenant may be null)ResolvedTenantContext— Context with guaranteed non-null tenant/dbDrizzleDatabase— Provider-agnostic Drizzle Postgres database typeDatabaseClientFactory—(url: string) => DrizzleDatabaseSwitchTenantTask— Plugin interface formakeCurrent/forgetCurrentTenantFinder— Plugin interface forfindForRequestTenancyConfig— Configuration object
Providers
neonClientFactory()— Built-in Neon provider
Errors
NoCurrentTenantError— Thrown byneedsTenant()when no tenant is setTenantNotFoundError— Thrown when a tenant lookup fails (e.g., in job handler)
License
MIT
