Framework Adapters
UseBetter Tenant has two kinds of adapters:
- ORM adapters — handle transactions,
SET LOCAL, and RLS bypass - Framework adapters — middleware that resolves the tenant and delegates to the ORM adapter
ORM adapters
Section titled “ORM adapters”Drizzle
Section titled “Drizzle”The Drizzle adapter wraps your queries in a transaction with set_config('app.current_tenant', '<uuid>', true) — the function form of SET LOCAL. It works with any Postgres driver that Drizzle supports.
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" },});import { drizzle } from "drizzle-orm/postgres-js";import postgres from "postgres";import { betterTenant } from "@usebetterdev/tenant";import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";
const client = postgres(process.env.DATABASE_URL);const database = drizzle(client);
export const tenant = betterTenant({ database: drizzleDatabase(database), tenantResolver: { header: "x-tenant-id" },});drizzleDatabase(database) bundles the adapter and tenant repository into a single database provider. If you use a custom tenants table, pass it via the table option:
import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";
export const tenant = betterTenant({ database: drizzleDatabase(database, { table: myCustomTenantsTable }), tenantResolver: { header: "x-tenant-id" },});What it does under the hood:
runWithTenant(tenantId, fn)— opens a Drizzle transaction, runsSELECT set_config('app.current_tenant', '<uuid>', true), executesfnwith the transaction handle, then commits.runAsSystem(fn)— opens a transaction withSELECT set_config('app.bypass_rls', 'true', true)for admin operations.
Tenant repository:
drizzleDatabase() includes a built-in tenant repository that provides getBySlug for slug-to-UUID resolution and powers the tenant.api CRUD operations. The tenants table must match the CLI-generated schema: id (UUID), name, slug, created_at.
Peer dependencies: drizzle-orm and either pg or postgres
Prisma
Section titled “Prisma”The Prisma adapter uses interactive transactions with $executeRaw for session variables. Requires Prisma 7+ with @prisma/adapter-pg.
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" },});prismaDatabase(prisma) bundles the adapter and tenant repository into a single database provider. If you use a custom table name, pass it via the tableName option:
import { prismaDatabase } from "@usebetterdev/tenant/prisma";
export const tenant = betterTenant({ database: prismaDatabase(prisma, { tableName: "my_tenants" }), tenantResolver: { header: "x-tenant-id" },});What it does under the hood:
runWithTenant(tenantId, fn)— opens a Prisma interactive$transaction, runsSET LOCALvia$executeRaw, executesfnwith the transaction client.runAsSystem(fn)— same pattern withapp.bypass_rls = 'true'.
getDatabase() return type: getDatabase() is fully typed via generics — when you pass a typed PrismaClient to prismaDatabase(), the transaction client type flows through to getDatabase() with all model methods. No casting needed.
See the Prisma guide for the wrapper pattern.
Peer dependencies: @prisma/client (>= 7.0.0) and @prisma/adapter-pg (>= 7.0.0)
For connection customization (SSL, pool settings), see the Prisma guide.
Framework adapters
Section titled “Framework adapters”Framework adapters are middleware that:
- Extract the tenant identifier from the request using your configured resolver
- Resolve the identifier to a UUID (slug lookup if needed)
- Delegate to the ORM adapter to open a transaction with
SET LOCAL - Run your handler inside that transaction
import { Hono } from "hono";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";import { tenant } from "./tenant"; // your betterTenant instanceimport { projectsTable } from "./schema";
const app = new Hono();
// Apply to specific routesapp.use("/api/*", createHonoMiddleware(tenant));
app.get("/api/projects", async (c) => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); return c.json(projects);});import { Hono } from "hono";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";import { tenant } from "./tenant";
const app = new Hono();app.use("/api/*", createHonoMiddleware(tenant));
app.get("/api/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);});Express
Section titled “Express”import express from "express";import { createExpressMiddleware } from "@usebetterdev/tenant/express";import { tenant } from "./tenant"; // your betterTenant instanceimport { projectsTable } from "./schema";
const app = express();
// Apply to specific routesapp.use("/api", createExpressMiddleware(tenant));
app.get("/api/projects", async (req, res) => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); res.json(projects);});import express from "express";import { createExpressMiddleware } from "@usebetterdev/tenant/express";import { tenant } from "./tenant";
const app = express();app.use(express.json());app.use("/api", createExpressMiddleware(tenant));
app.get("/api/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);});
app.post("/api/projects", async (req, res) => { const db = tenant.getDatabase(); if (!db) { return res.status(500).json({ error: "No tenant-scoped database" }); } const project = await db.project.create({ data: { name: req.body.name } }); res.status(201).json(project);});Next.js App Router
Section titled “Next.js App Router”Next.js uses a per-route wrapper instead of global middleware:
import { withTenant } from "@usebetterdev/tenant/next";import { tenant } from "@/lib/tenant";import { projectsTable } from "@/schema";
export const GET = withTenant(tenant, async (request) => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); return Response.json(projects);});
export const POST = withTenant(tenant, async (request) => { const body = await request.json(); const db = tenant.getDatabase(); await db.insert(projectsTable).values(body); return Response.json({ ok: true }, { status: 201 });});import { 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);});
export const POST = withTenant(tenant, async (request) => { const body = await request.json(); const db = tenant.getDatabase(); if (!db) { return Response.json({ error: "No tenant-scoped database" }, { status: 500 }); } const project = await db.project.create({ data: body }); return Response.json(project, { status: 201 });});Mixing tenant and non-tenant routes
Section titled “Mixing tenant and non-tenant routes”Most apps have routes that don’t need tenant context — health checks, global config, onboarding, authentication. The tenant middleware requires a valid tenant identifier on every request it handles, so applying it too broadly (e.g., app.use("*", ...)) will reject requests to non-tenant routes.
The fix: scope the middleware to only the routes that need it. Non-tenant routes use your database connection directly — they don’t go through the tenant middleware at all.
import { Hono } from "hono";import { createHonoMiddleware } from "@usebetterdev/tenant/hono";import { tenant } from "./tenant";import { db } from "./database";import { projectsTable, globalConfigTable } from "./schema";
const app = new Hono();
// Non-tenant routes — no middleware, use db directlyapp.get("/api/health", (c) => c.json({ ok: true }));app.get("/api/global-config", async (c) => { const config = await db.select().from(globalConfigTable); return c.json(config);});
// Tenant routes — middleware scoped to these pathsapp.use("/api/projects/*", createHonoMiddleware(tenant));app.use("/api/tasks/*", createHonoMiddleware(tenant));
app.get("/api/projects", async (c) => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); return c.json(projects);});import express from "express";import { createExpressMiddleware } from "@usebetterdev/tenant/express";import { tenant } from "./tenant";import { db } from "./database";import { projectsTable, globalConfigTable } from "./schema";
const app = express();
// Non-tenant routes — no middleware, use db directlyapp.get("/api/health", (req, res) => res.json({ ok: true }));app.get("/api/global-config", async (req, res) => { const config = await db.select().from(globalConfigTable); res.json(config);});
// Tenant routes — middleware scoped via routerconst tenantRouter = express.Router();tenantRouter.use(createExpressMiddleware(tenant));
tenantRouter.get("/projects", async (req, res) => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); res.json(projects);});
app.use("/api", tenantRouter);Next.js uses per-route wrappers, so this works naturally — only wrap routes that need tenant context:
// app/api/projects/route.ts — tenant-scopedimport { withTenant } from "@usebetterdev/tenant/next";import { tenant } from "@/lib/tenant";import { projectsTable } from "@/schema";
export const GET = withTenant(tenant, async () => { const db = tenant.getDatabase(); const projects = await db.select().from(projectsTable); return Response.json(projects);});// app/api/global-config/route.ts — no tenant neededimport { db } from "@/lib/database";import { globalConfigTable } from "@/schema";
export async function GET() { const config = await db.select().from(globalConfigTable); return Response.json(config);}Choosing your stack
Section titled “Choosing your stack”| ORM | Driver | Framework | Best for |
|---|---|---|---|
| Drizzle | pg | Hono | Traditional Node.js APIs |
| Drizzle | postgres.js | Hono | Lightweight APIs, edge-compatible |
| Drizzle | pg | Express | Existing Express apps, REST APIs |
| Drizzle | pg / postgres.js | Next.js | Full-stack React apps |
| Prisma | (managed by Prisma) | Express | Prisma-first projects, existing Express apps |
| Prisma | (managed by Prisma) | Hono | Prisma-first projects, lightweight APIs |
| Prisma | (managed by Prisma) | Next.js | Next.js + Prisma projects |
For Prisma-specific details (schema requirements, getDatabase() typing, migration workflow), see the dedicated Prisma guide.
Next steps
Section titled “Next steps”- Configuration — resolver strategies, tenant API, and admin operations
- CLI & Migrations — generate migrations, verify RLS, and seed tenants
- Architecture — how transaction-scoped RLS and bypass work under the hood