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.
Same pattern, every product
Section titled “Same pattern, every product”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.
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 levelapp.get("/api/projects", async (c) => { const projects = await tenant.getDatabase() .select().from(projectsTable); return c.json(projects);});import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });const db = drizzle(pool);
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders"],});
// Wrap your database — insert/update/delete are captured automaticallyconst auditedDb = withAuditProxy(db, audit.captureLog);
await auditedDb.insert(usersTable).values({ name: "Alice" });// → audit_logs entry: INSERT on users, after: { name: "Alice" }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.
Your database, not a service
Section titled “Your database, not a service”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:
# Generate RLS policies and triggersnpx @usebetterdev/tenant-cli migrate -o ./migrations/rls
# Run your ORM's migration tool to apply the SQL, then verify setupnpx @usebetterdev/tenant-cli check --database-url $DATABASE_URL# Preview the audit_logs table migrationnpx @usebetterdev/audit-cli migrate --dry-run
# Run your ORM's migration tool to apply the SQL, then verify setupnpx @usebetterdev/audit-cli check --database-url $DATABASE_URLBecause 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_atFROM audit_logsWHERE table_name = 'users' AND created_at > now() - interval '1 hour'ORDER BY created_at DESC;Swap anything
Section titled “Swap anything”Every product is built from independent layers. Each layer has a single responsibility, and you can swap any layer without affecting the others:
| Layer | Responsibility | Examples |
|---|---|---|
| Core | Types, context, adapter contracts. Zero runtime dependencies. | tenant-core, audit-core |
| ORM adapters | Implement the core contract for a specific ORM. | tenant-drizzle, tenant-prisma, audit-drizzle |
| Framework adapters | Middleware for web frameworks. | tenant-hono, tenant-express, tenant-next |
| CLI | Migrations, health checks, scaffolding. | tenant-cli, audit-cli |
| Main umbrella | Re-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));import { createExpressMiddleware } from "@usebetterdev/tenant/express";
app.use("/api", createExpressMiddleware(tenant));import { withTenant } from "@usebetterdev/tenant/next";
export const GET = withTenant(tenant, async (req) => { const db = tenant.getDatabase(); return Response.json(await db.select().from(projectsTable));});Your betterTenant() configuration, database adapter, and application logic stay exactly the same — only the framework import changes.
Type-safe end to end
Section titled “Type-safe end to end”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 preservedconst db = tenant.getDatabase();const projects = await db.select().from(projectsTable);// ^? { id: number; name: string; tenantId: string }[]
// Query builder is typed tooconst 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.
Next steps
Section titled “Next steps”Pick your starting point:
- Tenant — Getting Started — request-scoped multi-tenancy with Postgres RLS
- Audit — Introduction — automatic mutation logging for compliance and debugging