Skip to content

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

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

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, runs SELECT set_config('app.current_tenant', '<uuid>', true), executes fn with the transaction handle, then commits.
  • runAsSystem(fn) — opens a transaction with SELECT 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

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, runs SET LOCAL via $executeRaw, executes fn with the transaction client.
  • runAsSystem(fn) — same pattern with app.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 are middleware that:

  1. Extract the tenant identifier from the request using your configured resolver
  2. Resolve the identifier to a UUID (slug lookup if needed)
  3. Delegate to the ORM adapter to open a transaction with SET LOCAL
  4. Run your handler inside that transaction
import { Hono } from "hono";
import { createHonoMiddleware } from "@usebetterdev/tenant/hono";
import { tenant } from "./tenant"; // your betterTenant instance
import { projectsTable } from "./schema";
const app = new Hono();
// Apply to specific routes
app.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 express from "express";
import { createExpressMiddleware } from "@usebetterdev/tenant/express";
import { tenant } from "./tenant"; // your betterTenant instance
import { projectsTable } from "./schema";
const app = express();
// Apply to specific routes
app.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);
});

Next.js uses a per-route wrapper instead of global middleware:

app/api/projects/route.ts
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 });
});

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 directly
app.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 paths
app.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);
});
ORMDriverFrameworkBest for
DrizzlepgHonoTraditional Node.js APIs
Drizzlepostgres.jsHonoLightweight APIs, edge-compatible
DrizzlepgExpressExisting Express apps, REST APIs
Drizzlepg / postgres.jsNext.jsFull-stack React apps
Prisma(managed by Prisma)ExpressPrisma-first projects, existing Express apps
Prisma(managed by Prisma)HonoPrisma-first projects, lightweight APIs
Prisma(managed by Prisma)Next.jsNext.js + Prisma projects

For Prisma-specific details (schema requirements, getDatabase() typing, migration workflow), see the dedicated Prisma guide.

  • 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