Quick Start
This guide walks you through adding multi-tenancy to an existing Postgres application. By the end, your queries will be automatically scoped to the current tenant via RLS — no WHERE tenant_id = ? needed.
Prerequisites
Section titled “Prerequisites”- A running PostgreSQL 13+ database
- An existing application with tables you want to make tenant-scoped
- Node.js 22+
- A non-superuser database role for your application
If you don’t have one yet, create an application role:
CREATE ROLE app_user WITH LOGIN PASSWORD 'app_password';GRANT CONNECT ON DATABASE mydb TO app_user;GRANT USAGE ON SCHEMA public TO app_user;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;Then use DATABASE_URL=postgresql://app_user:app_password@localhost:5432/mydb.
-
Initialize config
Terminal window npx @usebetterdev/tenant-cli init --database-url $DATABASE_URLThis connects to your database, detects your tables, and creates
better-tenant.config.json:{"tenantTables": ["projects", "tasks"]} -
Set up schema and RLS
Your ORM manages the schema (tables, columns). The CLI generates RLS policies and triggers.
Add
tenantsTable,tenantId, and.enableRLS()to each tenant-scoped table in your Drizzle schema:import { pgTable, serial, text } from "drizzle-orm/pg-core";import { tenantsTable, tenantId } from "@usebetterdev/tenant/drizzle";export { tenantsTable };export const projectsTable = pgTable("projects", {id: serial("id").primaryKey(),name: text("name").notNull(),...tenantId,}).enableRLS();...tenantIdadds atenant_idcolumn:UUID NOT NULL, referencestenants(id), with a default from the PostgreSQL session variableapp.current_tenant. You can write it manually instead — see the Manual tab in CLI & Migrations.Unique constraints: If your tables have
UNIQUEconstraints (e.g., onemailorslug), you likely need to convert them to per-tenant composites — see Configuration — Unique constraints.Then generate and apply migrations:
Terminal window # Schema migration (creates tenants table + tenant_id columns)npx drizzle-kit generatenpx drizzle-kit migrate# RLS migration (policies + triggers)npx drizzle-kit generate --custom --name=better_tenant_rls --prefix=nonenpx @usebetterdev/tenant-cli migrate -o drizzle/_better_tenant_rls.sqlnpx drizzle-kit migrateAdd the
Tenantmodel andtenantIdto each tenant-scoped model inschema.prisma:model Tenant {id String @id @default(uuid()) @db.Uuidname Stringslug String @uniquecreatedAt DateTime @default(now()) @map("created_at") @db.Timestamptzprojects Project[]@@map("tenants")}model Project {id Int @id @default(autoincrement())name StringtenantId String @map("tenant_id") @db.Uuidtenant Tenant @relation(fields: [tenantId], references: [id])@@map("projects")}Then generate and apply migrations:
Terminal window # Schema migration (creates tenants table + tenant_id columns)npx prisma migrate dev --name setup# Create a draft migration for RLS (--create-only generates the file without applying)npx prisma migrate dev --create-only --name better_tenant_rls# Fill it with RLS policies + triggersnpx @usebetterdev/tenant-cli migrate \-o prisma/migrations/*_better_tenant_rls/migration.sql# Apply the RLS migrationnpx prisma migrate dev -
Verify the setup
Terminal window npx @usebetterdev/tenant-cli check --database-url $DATABASE_URLThe check command runs 10+ validations to confirm RLS is correctly configured.
-
Seed a test tenant
Terminal window npx @usebetterdev/tenant-cli seed --name "Acme Corp" --database-url $DATABASE_URLTerminal window ✓ Created tenant: Acme Corp (acme-corp)d4f8e2a1-3b5c-4e7f-9a1d-6c8b2e4f0a3dCopy the UUID — you’ll use it to test your app in the last step.
-
Wire up the tenant instance
import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { Hono } from "hono";import { betterTenant } from "@usebetterdev/tenant";import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";import { projectsTable } from "./schema"; // your Drizzle table definitionconst pool = new Pool({ connectionString: process.env.DATABASE_URL });const database = drizzle(pool);const tenant = betterTenant({database: drizzleDatabase(database),tenantResolver: { header: "x-tenant-id" },});const app = new Hono();app.use("*", createHonoMiddleware(tenant));app.get("/projects", async (c) => {const db = tenant.getDatabase();const projects = await db.select().from(projectsTable);return c.json(projects);});import { drizzle } from "drizzle-orm/postgres-js";import postgres from "postgres";import { Hono } from "hono";import { betterTenant } from "@usebetterdev/tenant";import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";import { projectsTable } from "./schema"; // your Drizzle table definitionconst client = postgres(process.env.DATABASE_URL);const database = drizzle(client);const tenant = betterTenant({database: drizzleDatabase(database),tenantResolver: { header: "x-tenant-id" },});const app = new Hono();app.use("*", createHonoMiddleware(tenant));app.get("/projects", async (c) => {const db = tenant.getDatabase();const projects = await db.select().from(projectsTable);return c.json(projects);});import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import express from "express";import { betterTenant } from "@usebetterdev/tenant";import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";import { createExpressMiddleware } from "@usebetterdev/tenant/express";import { projectsTable } from "./schema"; // your Drizzle table definitionconst pool = new Pool({ connectionString: process.env.DATABASE_URL });const database = drizzle(pool);const tenant = betterTenant({database: drizzleDatabase(database),tenantResolver: { header: "x-tenant-id" },});const app = express();app.use(createExpressMiddleware(tenant));app.get("/projects", async (req, res) => {const db = tenant.getDatabase();const projects = await db.select().from(projectsTable);res.json(projects);});import { PrismaClient } from "./generated/prisma/client.js";import { PrismaPg } from "@prisma/adapter-pg";import express from "express";import { betterTenant } from "@usebetterdev/tenant";import { prismaDatabase } from "@usebetterdev/tenant/prisma";import { createExpressMiddleware } from "@usebetterdev/tenant/express";const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });const prisma = new PrismaClient({ adapter });const tenant = betterTenant({database: prismaDatabase(prisma),tenantResolver: { header: "x-tenant-id" },});const app = express();app.use(express.json());app.use(createExpressMiddleware(tenant));app.get("/projects", async (req, res) => {const db = tenant.getDatabase();if (!db) { return res.status(500).json({ error: "No tenant-scoped database" }); }const projects = await db.project.findMany();res.json(projects);});getDatabase()is fully typed via generics — no cast needed. See the Prisma guide for details.import { PrismaClient } from "./generated/prisma/client.js";import { PrismaPg } from "@prisma/adapter-pg";import { Hono } from "hono";import { betterTenant } from "@usebetterdev/tenant";import { prismaDatabase } from "@usebetterdev/tenant/prisma";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });const prisma = new PrismaClient({ adapter });const tenant = betterTenant({database: prismaDatabase(prisma),tenantResolver: { header: "x-tenant-id" },});const app = new Hono();app.use("*", createHonoMiddleware(tenant));app.get("/projects", async (c) => {const db = tenant.getDatabase();if (!db) { return c.json({ error: "No tenant-scoped database" }, 500); }const projects = await db.project.findMany();return c.json(projects);});lib/tenant.ts import { PrismaClient } from "./generated/prisma/client.js";import { PrismaPg } from "@prisma/adapter-pg";import { betterTenant } from "@usebetterdev/tenant";import { prismaDatabase } from "@usebetterdev/tenant/prisma";const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });const prisma = new PrismaClient({ adapter });export const tenant = betterTenant({database: prismaDatabase(prisma),tenantResolver: { header: "x-tenant-id" },});// app/api/projects/route.tsimport { withTenant } from "@usebetterdev/tenant/next";import { tenant } from "@/lib/tenant";export const GET = withTenant(tenant, async () => {const db = tenant.getDatabase();if (!db) { return Response.json({ error: "No tenant-scoped database" }, { status: 500 }); }const projects = await db.project.findMany();return Response.json(projects);});lib/tenant.ts import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { betterTenant } from "@usebetterdev/tenant";import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";const pool = new Pool({ connectionString: process.env.DATABASE_URL });const database = drizzle(pool);export const tenant = betterTenant({database: drizzleDatabase(database),tenantResolver: { header: "x-tenant-id" },});// app/api/projects/route.tsimport { withTenant } from "@usebetterdev/tenant/next";import { tenant } from "@/lib/tenant";import { projectsTable } from "@/schema"; // your Drizzle table definitionexport const GET = withTenant(tenant, async (request) => {const db = tenant.getDatabase();const projects = await db.select().from(projectsTable);return Response.json(projects);}); -
Test it
Use the UUID from the seed output:
Terminal window curl -H "x-tenant-id: d4f8e2a1-3b5c-4e7f-9a1d-6c8b2e4f0a3d" http://localhost:3000/projectsThe response contains only projects belonging to that tenant. No
WHEREclause needed — RLS handles it.
What just happened?
Section titled “What just happened?”- Your ORM created the
tenantstable andtenant_idcolumns. The CLI generated RLS policies for thetenantslookup table (open SELECT, writes requirebypass_rls) and RLS policies and triggers for your tenant-scoped tables. - Your app resolves the tenant ID from the
x-tenant-idheader on each request. - The adapter opens a transaction and runs
SELECT set_config('app.current_tenant', '<uuid>', true)— the function form ofSET LOCAL. (Drizzle uses a Drizzle transaction; Prisma uses an interactive$transaction.) - RLS automatically filters every query to the current tenant’s rows.
- When the transaction commits, the session variable is cleared — safe for connection pooling.
Next steps
Section titled “Next steps”- How RLS Works — understand the full request lifecycle, why connection pooling is safe, and what Postgres guarantees you get for free
- Configuration — resolver strategies, tenant API, admin operations
- Framework Adapters — detailed usage for each framework
- CLI & Migrations — all CLI commands and workflows