Skip to content

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:

  1. Headerx-tenant-id (or custom header name)
  2. Path — URL path segment (e.g., /t/:tenantId/*)
  3. Subdomain — first subdomain (e.g., acme.app.comacme)
  4. JWT — claim from a decoded JWT
  5. 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:

PatternStrategyExample
API with headerheader: "x-tenant-id"curl -H "x-tenant-id: <uuid>"
Subdomain routingsubdomain: trueacme.app.com
Path-based routingpath: "/t/:tenantId/*"/t/acme/projects
Auth-basedjwt: { 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 RLS

If 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 tenant
const 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 tenant
await tenant.api.updateTenant(created.id, {
name: "Acme Inc",
slug: "acme-inc",
});
// Delete a tenant
await tenant.api.deleteTenant(created.id);
MethodDescription
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 context
const ctx = tenant.getContext();
ctx.tenantId; // "550e8400-..."
ctx.tenant; // { id, name, slug, createdAt }
ctx.isSystem; // false (true inside runAsSystem)
// Get the tenant-scoped database handle
const 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 });
});

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:

src/database.ts
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:

src/handlers/projects.ts
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:

Terminal window
BETTER_TENANT_TELEMETRY=0

Next steps