Skip to content

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.

  • 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.

  1. Initialize config

    Terminal window
    npx @usebetterdev/tenant-cli init --database-url $DATABASE_URL

    This connects to your database, detects your tables, and creates better-tenant.config.json:

    {
    "tenantTables": ["projects", "tasks"]
    }
  2. 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();

    ...tenantId adds a tenant_id column: UUID NOT NULL, references tenants(id), with a default from the PostgreSQL session variable app.current_tenant. You can write it manually instead — see the Manual tab in CLI & Migrations.

    Unique constraints: If your tables have UNIQUE constraints (e.g., on email or slug), 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 generate
    npx drizzle-kit migrate
    # RLS migration (policies + triggers)
    npx drizzle-kit generate --custom --name=better_tenant_rls --prefix=none
    npx @usebetterdev/tenant-cli migrate -o drizzle/_better_tenant_rls.sql
    npx drizzle-kit migrate
  3. Verify the setup

    Terminal window
    npx @usebetterdev/tenant-cli check --database-url $DATABASE_URL

    The check command runs 10+ validations to confirm RLS is correctly configured.

  4. Seed a test tenant

    Terminal window
    npx @usebetterdev/tenant-cli seed --name "Acme Corp" --database-url $DATABASE_URL
    Terminal window
    ✓ Created tenant: Acme Corp (acme-corp)
    d4f8e2a1-3b5c-4e7f-9a1d-6c8b2e4f0a3d

    Copy the UUID — you’ll use it to test your app in the last step.

  5. 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 definition
    const 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);
    });
  6. Test it

    Use the UUID from the seed output:

    Terminal window
    curl -H "x-tenant-id: d4f8e2a1-3b5c-4e7f-9a1d-6c8b2e4f0a3d" http://localhost:3000/projects

    The response contains only projects belonging to that tenant. No WHERE clause needed — RLS handles it.

  1. Your ORM created the tenants table and tenant_id columns. The CLI generated RLS policies for the tenants lookup table (open SELECT, writes require bypass_rls) and RLS policies and triggers for your tenant-scoped tables.
  2. Your app resolves the tenant ID from the x-tenant-id header on each request.
  3. The adapter opens a transaction and runs SELECT set_config('app.current_tenant', '<uuid>', true) — the function form of SET LOCAL. (Drizzle uses a Drizzle transaction; Prisma uses an interactive $transaction.)
  4. RLS automatically filters every query to the current tenant’s rows.
  5. When the transaction commits, the session variable is cleared — safe for connection pooling.
  • 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