tenantkit
v0.2.0
Published
Composable multi-tenancy primitives for modern TypeScript SaaS apps.
Maintainers
Readme
TenantKit
Small multi-tenancy primitives for TypeScript SaaS apps.
Resolve the tenant once, bind it to the current request, and make tenant-scoped data access harder to forget.
flowchart LR
Request[Incoming request] --> Resolve[Resolve tenant]
Resolve --> Context[Bind tenant context]
Context --> Handler[Run app handler]
Handler --> Data[Scope Drizzle queries]
Data --> RLS[Optional Postgres RLS]Why
Tenant checks are easy to miss when every handler carries its own tenantId condition.
Before:
const projects = await db.query.projects.findMany({
where: and(eq(projects.tenantId, tenant.id), eq(projects.ownerId, userId)),
});After:
const tenantDb = createTenantDb(db);
const projects = await tenantDb.query(projects).findMany({
where: eq(projects.ownerId, userId),
});tenantkit does not replace database constraints or authorization. It gives your app a small, typed path for tenant resolution, request context, and common tenant-scoped queries.
What Tenancy Means
Auth answers: who is this user?
Tenancy answers: which customer, workspace, org, or account is this request operating inside?
flowchart LR
User[Signed-in user] --> Membership[Org membership]
Membership --> Acme[Acme workspace]
Membership --> Beta[Beta workspace]
Acme --> AcmeData[Acme projects, billing, roles]
Beta --> BetaData[Beta projects, billing, roles]A simple auth-gated dashboard is enough when each user only owns their own data. Tenancy matters when the product boundary is bigger than a user: teams, workspaces, customer accounts, custom domains, per-account billing, tenant-specific roles, shared projects, API keys, audit logs, or webhooks.
That distinction shows up quickly:
// User-scoped app
const projects = await db.query.projects.findMany({
where: eq(projects.ownerId, user.id),
});
// Tenant-scoped app
const projects = await tenantDb.query(projects).findMany({
where: eq(projects.ownerId, user.id),
});In the tenant-scoped version, ownerId is still useful. It narrows data inside the tenant. The tenant boundary stays separate and is applied first.
Use tenantkit when your app needs to consistently answer:
- Which tenant did this request resolve to?
- Is this tenant active, trialing, or suspended?
- Which user and role are acting inside this tenant?
- Which rows, jobs, files, webhooks, or API calls belong to this tenant?
If the app is truly one-user-one-dashboard, tenantkit is probably too much. If the app is B2B, workspace-based, org-based, or customer-domain-based, tenancy is core infrastructure.
Install
Install tenantkit from npm:
bun add tenantkitFor local package testing after a build:
bun pm pack
bun add ./tenantkit-0.1.0.tgzInstall only the optional peers you use:
bun add drizzle-orm honoAdditional adapters use their own peers:
bun add better-auth elysia nextWhat You Get
tenantkit: core tenant types, typed errors, resolvers, and request context.tenantkit/drizzle: PostgreSQL-first helpers for scoped selects, inserts, and RLS context.tenantkit/hono: Hono middleware with tenant variables and lifecycle cleanup.tenantkit/elysia: Elysia plugin with scoped handler fields and lifecycle cleanup.tenantkit/next: Next middleware header forwarding and server-side context helpers.tenantkit/better-auth: Better Auth organization-to-tenant bridge.
Quickstart
1. Define Tenant-Aware Tables
tenantTable keeps tenant-owned tables visually explicit. You still define the tenantId column yourself.
import { text, uuid } from "drizzle-orm/pg-core";
import { tenantTable } from "tenantkit/drizzle";
export const projects = tenantTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull(),
ownerId: text("owner_id").notNull(),
name: text("name").notNull(),
});2. Create A Resolver
Resolvers turn request signals into a Tenant.
import { chainResolvers, customDomainResolver, pathResolver, subdomainResolver } from "tenantkit";
const resolver = chainResolvers(
customDomainResolver({ lookup: lookupTenantByHost }),
subdomainResolver({
rootDomain: "yourapp.com",
lookup: lookupTenantBySlug,
}),
pathResolver({ lookup: lookupTenantBySlug }),
);Resolvers throw typed tenancy errors for tenant failures. Lookup adapter errors pass through unchanged.
3. Bind Tenant Context
Use the adapter for your framework. Hono example:
import { Hono } from "hono";
import { TenancyError } from "tenantkit";
import { tenancyMiddleware } from "tenantkit/hono";
const app = new Hono();
app.use(
"*",
tenancyMiddleware({
resolver,
resolveUser: async (context) => ({
userId: context.req.header("x-user-id"),
role: "member",
}),
onError: (error, context) => {
if (error instanceof TenancyError) {
return context.json({ error: error.message, code: error.code }, error.status ?? 500);
}
throw error;
},
}),
);Handlers can read tenant state from framework fields or from the current tenant context:
import { getTenant } from "tenantkit";
app.get("/projects", (context) => {
const tenant = getTenant();
return context.json({
tenantId: tenant.id,
tenantFromHono: context.get("tenant").id,
});
});4. Query Through createTenantDb
createTenantDb intentionally covers a narrow, high-value Drizzle surface: scoped findMany, scoped findFirst, and inserts that inject tenantId.
import { eq } from "drizzle-orm";
import { createTenantDb } from "tenantkit/drizzle";
const tenantDb = createTenantDb(db);
const projectList = await tenantDb.query(projects).findMany({
where: eq(projects.ownerId, userId),
});
await tenantDb.insert(projects).values({
ownerId: userId,
name: "Launch",
});For custom selects, updates, deletes, joins, and aggregates, use regular Drizzle with tenantFilter(table):
import { and, eq } from "drizzle-orm";
import { tenantFilter } from "tenantkit/drizzle";
await db
.select()
.from(projects)
.where(and(tenantFilter(projects), eq(projects.ownerId, userId)));Core API
import { getTenant, getTenantContext, getTenantOrNull, requireUser, withTenant } from "tenantkit";
interface AppUser {
id: string;
email: string;
}
await withTenant({ tenant }, async () => {
const tenant = getTenant();
const context = getTenantContext<AppUser>();
const maybeContext = getTenantOrNull();
const user = requireUser<AppUser>();
});Suspended tenants are rejected by default. Trialing tenants are treated as active. Pass a custom tenant validator when an app needs recovery pages for suspended tenants.
Framework Adapters
Hono
tenancyMiddleware binds tenant context around the request handler and exposes Hono variables:
tenanttenantContexttenantIduseruserIdrole
Tenant context is restored after successful handlers and thrown handler errors.
Elysia
import { Elysia } from "elysia";
import { getTenant } from "tenantkit";
import { tenancyPlugin } from "tenantkit/elysia";
const app = new Elysia()
.use(
tenancyPlugin({
resolver,
resolveUser: async (context) => ({
userId: context.headers["x-user-id"],
role: "member",
}),
}),
)
.get("/projects", ({ tenantId }) => ({
tenantId,
tenantFromContext: getTenant().id,
}));The plugin exposes tenant fields on Elysia handler context and restores tenant context on success and error paths.
Next.js
Next middleware/proxy and server code do not share the same request context. Middleware can resolve the tenant and forward trusted tenant headers:
import { nextTenancyMiddleware } from "tenantkit/next";
export default nextTenancyMiddleware({
resolver,
resolveUser: async (request) => ({
userId: request.headers.get("x-user-id") ?? undefined,
role: "member",
}),
});Bind tenant context again where server code runs:
import { getTenant } from "tenantkit";
import { withTenantRoute } from "tenantkit/next";
export async function GET(request: Request) {
return withTenantRoute(request, { resolver }, () => {
return Response.json({ tenantId: getTenant().id });
});
}withTenantRoute and withTenantHeaders bind tenant context only for their callback and restore it afterward.
Auth Recipes
resolveUser can return an opaque user object alongside userId and role. The auth provider still owns roles, permissions, and authorization.
Better Auth bridge:
import { betterAuthOrganizationResolver, resolveBetterAuthUser } from "tenantkit/better-auth";
const resolver = betterAuthOrganizationResolver({
auth,
lookup: lookupTenantByOrganizationId,
});
const resolveUser = resolveBetterAuthUser({ auth });By default, resolveBetterAuthUser compares the session's active organization against tenant.metadata.organizationId, falling back to tenant.id. If your tenant stores the organization id elsewhere, pass getTenantOrganizationId.
Use requireUser<TUser>() in handlers that need an authenticated user. For providers without a first-party bridge, copy the resolveUser shape from examples/hono-clerk or examples/next-authjs.
Production Safety
App-level helpers reduce mistakes. They do not replace database isolation.
For Postgres, pair scoped helpers with row-level security so custom SQL and missed filters still fail closed.
import { withTenantRls } from "tenantkit/drizzle";
await withTenantRls(db, async (tx) => {
return tx.select().from(projects);
});withTenantRls opens a transaction and sets app.tenant_id from the current tenant context with Postgres set_config(..., true). Policies can then enforce tenant ownership:
tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuidSee the Postgres docs for row security policies and set_config.
Runtime Contract
tenantkit uses AsyncLocalStorage from node:async_hooks for request context.
Use it in Node.js, Bun, or runtimes that provide compatible AsyncLocalStorage behavior. Do not assume edge compatibility yet. This matters most for Next middleware and Hono deployments on worker-style runtimes.
If your runtime cannot provide AsyncLocalStorage, use framework-local variables or forwarded headers as the source of truth instead of calling getTenant() inside that runtime.
Current Boundaries
Included:
- Core tenant types, typed errors, tenant context, validators, and resolvers.
- Generic
TenantContext<TUser>andrequireUser<TUser>(). - PostgreSQL-first Drizzle helpers for scoped
findMany,findFirst, and inserts. tenantFilter(table)for unsupported Drizzle operations.- Hono middleware, Elysia plugin, Next.js helpers, and Better Auth bridge.
- Optional Postgres RLS context helpers.
Not included:
- Polar integration.
- Prisma integration.
- Plan enforcement helpers.
- Core RBAC helpers such as
requireRoleorrequirePermission. - Built-in caching.
- Automatic update, delete, join, aggregate, or cross-dialect Drizzle scoping.
- Guaranteed edge-runtime support.
Examples
examples/hono-drizzle: database-free Hono + Drizzle walkthrough.examples/hono-drizzle-postgres: real Postgres example with RLS policies and seeded tenant data.examples/hono-better-auth: runnable Hono + Better Auth bridge.examples/hono-clerk: Clerk Organizations recipe.examples/elysia-basic: small Elysia plugin example.examples/elysia-drizzle-postgres: real Elysia + Drizzle + Postgres RLS example.examples/next-basic: Next.js middleware and route-handler example.examples/next-authjs: Auth.js session recipe.examples/next-portless: Next.js + Portless demo for wildcard local tenant URLs.
License
Apache-2.0. See LICENSE.
