Skip to content

Configuration

The resolver determines how tenant identity is extracted from incoming requests. You configure it when creating the betterTenant instance.

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.

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

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 RLS

If the identifier is already a UUID, it passes through unchanged — no lookup needed.

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.

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.
updateTenant(id, { name?, slug? })Update a tenant by ID. Returns updated tenant.
deleteTenant(id)Delete a tenant by ID.

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.

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
});

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);

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() 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 safety

See the Prisma guide for the recommended wrapper pattern.

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 });
});
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:

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.

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 email
export 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.

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:

Terminal window
BETTER_TENANT_TELEMETRY=0

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.