Configuration
Tenant resolver
The resolver determines how tenant identity is extracted from incoming requests. You configure it when creating the betterTenant instance.
Resolution order
When multiple strategies are configured, they are tried in this order:
- Header —
x-tenant-id(or custom header name) - Path — URL path segment (e.g.,
/t/:tenantId/*) - Subdomain — first subdomain (e.g.,
acme.app.com→acme) - JWT — claim from a decoded JWT
- Custom — your own function
The first strategy that returns a non-empty value wins.
Strategies
const tenant = betterTenant({ database: drizzleDatabase(db), tenantResolver: { // From a request header header: "x-tenant-id",
// From a URL path segment path: "/t/:tenantId/*",
// From subdomain (acme.app.com → "acme") subdomain: true,
// From a JWT claim jwt: { claim: "tenant_id" },
// Custom function custom: (req) => extractTenantFromRequest(req), },});You typically only need one strategy. The most common patterns:
| Pattern | Strategy | Example |
|---|---|---|
| API with header | header: "x-tenant-id" | curl -H "x-tenant-id: <uuid>" |
| Subdomain routing | subdomain: true | acme.app.com |
| Path-based routing | path: "/t/:tenantId/*" | /t/acme/projects |
| Auth-based | jwt: { claim: "tenant_id" } | Tenant ID embedded in token |
Slug-to-UUID resolution
RLS requires a UUID for SET LOCAL. When your resolver returns a slug (like "acme" from a subdomain), Better Tenant automatically looks it up in the tenants table and uses the matching UUID.
This works out of the box — the database provider always includes a tenant repository:
const tenant = betterTenant({ database: drizzleDatabase(db), tenantResolver: { subdomain: true },});// acme.app.com → extracts "acme" → finds tenant by slug → uses its UUID for RLSIf the identifier is already a UUID, it passes through unchanged — no lookup needed.
Custom ID resolution
For non-standard mappings (e.g., custom domains → tenant UUIDs), use resolveToId:
tenantResolver: { custom: (req) => req.host, // "client.com" resolveToId: async (domain) => { const mapping = await lookupCustomDomain(domain); return mapping.tenantId; // UUID },}When resolveToId is provided, auto-resolution is skipped entirely. The library trusts your transform.
Tenant API
CRUD operations on the tenants table are available via tenant.api. All API calls run with RLS bypass (runAsSystem):
// Create a tenantconst created = await tenant.api.createTenant({ name: "Acme Corp", slug: "acme",});
// List tenants (paginated)const tenants = await tenant.api.listTenants({ limit: 20, offset: 0 });
// Update a tenantawait tenant.api.updateTenant(created.id, { name: "Acme Inc", slug: "acme-inc",});
// Delete a tenantawait tenant.api.deleteTenant(created.id);| Method | Description |
|---|---|
createTenant({ name, slug }) | Create a tenant. Both fields required. Returns the created tenant. |
listTenants({ limit?, offset? }) | List tenants. Default limit 50, max 50. |
updateTenant(id, { name?, slug? }) | Update a tenant by ID. Returns updated tenant. |
deleteTenant(id) | Delete a tenant by ID. |
Admin operations
runAs — impersonate a tenant
Run a function as a specific tenant. Useful for background jobs and cron tasks:
await tenant.runAs(tenantId, async (db) => { const projects = await db.select().from(projectsTable); // scoped to the specified tenant});The tenantId must be a valid UUID. runAs does not resolve slugs.
runAsSystem — bypass RLS
Run a function with RLS bypass for cross-tenant operations:
await tenant.runAsSystem(async (db) => { const allProjects = await db.select().from(projectsTable); // returns projects from ALL tenants});Context access
Inside a tenant-scoped request or runAs call, you can access the current tenant context from anywhere in the call tree:
// Get the current tenant contextconst ctx = tenant.getContext();ctx.tenantId; // "550e8400-..."ctx.tenant; // { id, name, slug, createdAt }ctx.isSystem; // false (true inside runAsSystem)
// Get the tenant-scoped database handleconst db = tenant.getDatabase();const projects = await db.select().from(projectsTable);This works because Better Tenant uses AsyncLocalStorage to propagate context through the call stack. No need to pass the database handle or tenant ID through function arguments.
Non-tenant tables
Not every table in your application needs tenant isolation. Lookup tables, feature flags, global settings, or shared reference data typically have no tenant_id column and no RLS policies. These tables work through tenant.getDatabase() with no extra setup.
RLS is opt-in per table in Postgres. The SET LOCAL app.current_tenant variable is set on the transaction, but only tables with ENABLE ROW LEVEL SECURITY and a matching policy actually filter rows. Tables without RLS ignore the session variable entirely — all rows are visible.
app.get("/projects", async (c) => { const db = tenant.getDatabase();
// Tenant-scoped (table has RLS) → only this tenant's rows const projects = await db.select().from(projectsTable);
// Shared (no RLS on this table) → all rows visible const categories = await db.select().from(categoriesTable);
return c.json({ projects, categories });});Recommended: wrap getDatabase() as your default database handle
Since tenant.getDatabase() returns a standard ORM transaction handle, you can use it as the single database access point for all queries in a request — tenant-scoped and shared alike. A thin wrapper makes this ergonomic:
import { tenant } from "./tenant";
export function getDatabase() { const database = tenant.getDatabase(); if (!database) { throw new Error( "No active tenant context — call getDatabase() inside a request or runAs/runAsSystem block", ); } return database;}Then use getDatabase() everywhere in your handlers and services:
import { getDatabase } from "../database";import { projectsTable } from "../schema/projects";import { categoriesTable } from "../schema/categories";
export async function listProjects() { const projects = await getDatabase().select().from(projectsTable); // RLS-filtered const categories = await getDatabase().select().from(categoriesTable); // no RLS, all rows return { projects, categories };}This pattern gives you:
- Single access point — no separate connection pool for shared tables, no confusion about which database handle to use.
- Consistent transactions — reads from shared tables participate in the same transaction as tenant-scoped reads, giving you a consistent snapshot.
Telemetry
Better Tenant collects anonymous telemetry by default (library version and runtime info). Opt out with:
const tenant = betterTenant({ // ... telemetry: { enabled: false },});Or via environment variable:
BETTER_TENANT_TELEMETRY=0Next steps
- Framework Adapters — per-adapter setup for Drizzle, Prisma, Hono, Express, and Next.js
- CLI & Migrations — generate migrations, verify RLS, and seed tenants
- Architecture — how transaction-scoped RLS works under the hood