Skip to content

Core Concepts

Every SaaS application needs the same infrastructure: tenancy audit logs webhooks transactional email. These are hard problems — row-level security policies, change-data capture, reliable delivery guarantees — and most teams rebuild them from scratch for every project.

UseBetter provides production-grade implementations for each of these concerns. Every product follows the same API pattern, runs in your database, and plugs into your existing ORM and framework. Learn one product and the rest feel immediately familiar.

Every UseBetter product starts with a better*() factory function. You pass in your database adapter and configuration, wire up middleware, and your application code stays clean.

src/tenant.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { betterTenant } from "@usebetterdev/tenant";
import { drizzleDatabase } from "@usebetterdev/tenant/drizzle";
import { createHonoMiddleware } from "@usebetterdev/tenant/hono";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
const tenant = betterTenant({
database: drizzleDatabase(db),
tenantResolver: { header: "x-tenant-id" },
});
app.use("/api/*", createHonoMiddleware(tenant));
// Every query is now scoped to the current tenant —
// Postgres RLS enforces isolation at the database level
app.get("/api/projects", async (c) => {
const projects = await tenant.getDatabase()
.select().from(projectsTable);
return c.json(projects);
});

The structure is the same in both: create an instance with better*(), pass a database adapter, and plug it into your app. Tenant uses middleware for request scoping. Audit uses a database proxy for automatic capture. The underlying pattern — configure once, then use your ORM normally — is identical.

UseBetter products store everything in your own PostgreSQL database. There is no external service, no data leaving your infrastructure, and no vendor dashboard between you and your data.

Each product ships a CLI that generates migration SQL for your ORM. You review the SQL, apply it with your existing migration tooling, and verify the setup:

Terminal window
# Generate RLS policies and triggers
npx @usebetterdev/tenant-cli migrate -o ./migrations/rls
# Run your ORM's migration tool to apply the SQL, then verify setup
npx @usebetterdev/tenant-cli check --database-url $DATABASE_URL

Because the data lives in your database, you can inspect it with plain SQL, back it up with your existing tools, and query it from any application that has database access:

-- Who modified the users table in the last hour?
SELECT actor_id, operation, record_id, created_at
FROM audit_logs
WHERE table_name = 'users'
AND created_at > now() - interval '1 hour'
ORDER BY created_at DESC;

Every product is built from independent layers. Each layer has a single responsibility, and you can swap any layer without affecting the others:

LayerResponsibilityExamples
CoreTypes, context, adapter contracts. Zero runtime dependencies.tenant-core, audit-core
ORM adaptersImplement the core contract for a specific ORM.tenant-drizzle, tenant-prisma, audit-drizzle
Framework adaptersMiddleware for web frameworks.tenant-hono, tenant-express, tenant-next
CLIMigrations, health checks, scaffolding.tenant-cli, audit-cli
Main umbrellaRe-exports everything via subpath imports.@usebetterdev/tenant, @usebetterdev/audit

Dependencies flow inward — framework adapters depend on core, but core never depends on adapters. This means switching frameworks is a one-line change:

import { createHonoMiddleware } from "@usebetterdev/tenant/hono";
app.use("/api/*", createHonoMiddleware(tenant));

Your betterTenant() configuration, database adapter, and application logic stay exactly the same — only the framework import changes.

All packages are written in strict TypeScript with noUncheckedIndexedAccess and exactOptionalPropertyTypes. Types flow from your schema through the adapter to your application code.

When you call tenant.getDatabase(), you get back a fully typed Drizzle or Prisma client scoped to the current tenant. When you call audit.query(), the query builder returns typed results. Configuration errors are caught at compile time, not at runtime:

const tenant = betterTenant({
database: drizzleDatabase(db),
tenantResolver: { header: "x-tenant-id" },
});
// Fully typed — db is your Drizzle client with all table types preserved
const db = tenant.getDatabase();
const projects = await db.select().from(projectsTable);
// ^? { id: number; name: string; tenantId: string }[]
// Query builder is typed too
const logs = await audit.query()
.resource("users")
.operation("DELETE")
.since("24h")
.list();
// ^? { entries: AuditLogEntry[] }

No any, no type assertions, no casting. If your schema changes, the compiler tells you everywhere that needs updating.

Pick your starting point: