Configuration
Tenant resolver
Section titled “Tenant resolver”The resolver determines how tenant identity is extracted from incoming requests. You configure it when creating the betterTenant instance.
Resolution order
Section titled “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
Section titled “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
Section titled “Slug-to-UUID resolution”RLS requires a UUID for SET LOCAL. When your resolver returns a slug (like "acme" from a subdomain), UseBetter 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
Section titled “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
Section titled “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. |
updateTenant(id, { name?, slug? }) | Update a tenant by ID. Returns updated tenant. |
deleteTenant(id) | Delete a tenant by ID. |
Admin operations
Section titled “Admin operations”runAs — impersonate a tenant
Section titled “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});await tenant.runAs(tenantId, async (db) => { const projects = await db.project.findMany(); // scoped to the specified tenant});The tenantId must be a valid UUID. runAs does not resolve slugs.
runAsSystem — bypass RLS
Section titled “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});await tenant.runAsSystem(async (db) => { const allProjects = await db.project.findMany(); // returns projects from ALL tenants});Context access
Section titled “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);Both getContext() and getDatabase() return undefined when called outside a tenant scope (e.g., outside middleware or a runAs block). See Troubleshooting — Context is undefined for common causes.
This works because UseBetter Tenant uses AsyncLocalStorage to propagate context through the call stack. No need to pass the database handle or tenant ID through function arguments.
getDatabase() with Prisma
Section titled “getDatabase() with Prisma”getDatabase() is fully typed via generics when you pass a typed PrismaClient to prismaDatabase(). No casting needed:
const db = tenant.getDatabase();if (!db) { throw new Error("No tenant-scoped database"); }const projects = await db.project.findMany(); // Full type safetySee the Prisma guide for the recommended wrapper pattern.
Non-tenant tables
Section titled “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
Section titled “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.
Unique constraints
Section titled “Unique constraints”When adding tenant_id to existing tables, you need to decide how existing UNIQUE constraints should behave:
- Per-tenant uniqueness (most common): Two tenants can have users with the same email. Convert the constraint to a composite
UNIQUE(tenant_id, email). - Global uniqueness: No two users across any tenant can share an email. Keep the existing
UNIQUE(email)constraint as-is.
UseBetter Tenant does not modify unique constraints automatically — you must update your schema.
import { pgTable, uuid, varchar, unique } from "drizzle-orm/pg-core";import { tenantId } from "@usebetterdev/tenant/drizzle";
// Per-tenant unique emailexport const usersTable = pgTable("users", { id: uuid("id").defaultRandom().primaryKey(), ...tenantId, email: varchar("email", { length: 255 }).notNull(),}, (table) => [ unique().on(table.tenantId, table.email),]).enableRLS();This replaces the original UNIQUE(email) with UNIQUE(tenant_id, email). Two tenants can now have users with the same email, but the same email cannot appear twice within a single tenant.
model User { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid email String tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, email]) @@map("users")}The @@unique([tenantId, email]) replaces a simple @unique on email. Remove the original @unique from the email field.
Telemetry
Section titled “Telemetry”UseBetter 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=0Console integration
Section titled “Console integration”UseBetter Tenant integrates seamlessly with UseBetter Console to provide a web-based admin dashboard for your tenants.
Pass your betterConsole instance to the console configuration option:
import { betterConsole } from "@usebetterdev/console";import { betterTenant } from "@usebetterdev/tenant";
const consoleInstance = betterConsole({ connectionTokenHash: process.env.BETTER_CONSOLE_TOKEN_HASH!, sessions: { autoApprove: process.env.NODE_ENV === "development" },});
const tenant = betterTenant({ database: ..., tenantResolver: ..., console: consoleInstance, // <--- Registers tenant endpoints automatically});This automatically registers the tenant product with the console, exposing endpoints for listing, creating, updating, and deleting tenants via the Console UI.
Next steps
Section titled “Next 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