Open source / base for verticals · 2026

multitenant-saas-base

Multi-tenant base with isolation enforced by Postgres

TypeScript base for multi-tenant SaaS: orgs and branches, roles, billing, capabilities and files, with tenant isolation enforced by Postgres RLS and eight structural guards.

Role

Author

Context

Open source / base for verticals

Year

2026

Links

Technologies: TypeScriptNext.jstRPCDrizzle ORMPostgreSQLbetter-authTurborepoVitest

Overview

multitenant-saas-base is a TypeScript base for building multi-tenant SaaS: identity and sessions, organizations and branches, roles and permissions editable per organization, billing, capabilities, feature flags, files, auditing and notifications, all with their enforcement already in place. In the model, organization is the client and team is the branch, and every tenant lives in a single pooled deploy.

It is not a finished product. It is the floor a vertical gets mounted on: the code is split into platform/, what exists in any multi-tenant SaaS, and modules/<name>, what gets taken out and added one at a time. Four factory modules ship with it, customers, opening-hours, payments and whatsapp, as the worked example of how the fifth one is added.

The problem

A generic boilerplate hands you screens and leaves the hard parts to good intentions. In a multi-tenant SaaS the three that break are always the same.

Isolation done with a where clause is one forgotten filter away from leaking one client's data to another, and nothing fails while you write it. Structure erodes: the split between what is shared base and what belongs to a single vertical is real on day one and gone by month six, because nothing stops an import. And tests can pass without proving anything: an isolation test run as a superuser is green because policies are never evaluated, not because isolation works.

So what I wanted from this base was not more features. It was that those three failures be impossible to commit without something turning red.

Decisions

  • Postgres RLS as the backstop instead of trusting the query layer: the primary enforcement is scoped query and mutation functions that receive orgId and teamId from the tRPC context, the single tenant injection point, and RLS catches whatever gets past it.
  • withTenantScope opens a transaction and sets the tenant through a transaction-local GUC with set_config: the COMMIT resets it on its own, so no state leaks between requests sharing a pooled connection, and policies read that GUC.
  • Declarative policies with pgPolicy and enableRLS on the Drizzle table, so they enter drizzle-kit generate: writing policy SQL by hand is forbidden, because a policy that lives outside the schema is one nobody diffs.
  • A FORCE ROW LEVEL SECURITY migration, verified against the catalog of a real Postgres: enabling RLS is not enough, since the table owner bypasses its own policies.
  • Tests run as a NOBYPASSRLS role, with the superuser left only for migrating: with a superuser every isolation test passes vacuously, green without having tested anything. The driver is non-edge postgres.js on purpose, because RLS needs session affinity and a pooled driver in statement mode breaks SET LOCAL.
  • platform/ never imports from modules/, and no module imports another: when the platform has to trigger something from a module it declares an injectable port, and whoever composes provides it. dependency-cruiser enforces the direction where it is a contract, in the packages.
  • Eight structural guards inside pnpm check:ci: dependency-cruiser boundaries plus guards for the boundaries config itself, the test pyramid, readmes, file size, comment ratio, misplaced directives and duplication.
  • The size and comment guards fail against frozen baselines instead of an ideal: what was already big is grandfathered but can only shrink, a dead entry also fails, and nobody joins the list without it showing in the diff. The debt cannot grow.
  • Meta-tests over properties of the code, not over behavior: 13 meta-tests and 12 convention tests cover the class of error that breaks nothing when written. authz-meta names any procedure without a declared permission, rls-meta any business table without org_id, RLS and a policy, schema-migration-drift-meta the schema that drifted from its last snapshot.
  • tRPC v11 as the only transport, no Server Actions: one surface to authorize, one place to inject the tenant, and typed clients for free.
  • 24 ADRs in docs/adr, each one written because someone was going to undo the decision without knowing its cost.

Stack

Turborepo with pnpm workspaces on Node 22, Next.js 16 with React 19 and Tailwind 4 on the web, tRPC v11 as the only transport, Drizzle ORM over Postgres 16 with declarative RLS, and better-auth with its organization plugin for identity.

Billing runs on two levels: Polar charges the organizations their subscription, MercadoPago is the checkout the tenant offers its own end customers. Files go to R2, WhatsApp through Kapso, email through Resend, and in-product onboarding uses driver.js tours with per user state.

Quality is Biome, dependency-cruiser, jscpd, sherif, lefthook, Vitest, Playwright and deepsec, all wired into the same command CI runs.

Result

A vertical that starts here gets the whole tenancy layer resolved and, more importantly, guarded: auth and sessions, orgs and branches, editable roles and permissions, subscriptions, capabilities, flags, files, jobs, auditing and notifications, with isolation that a forgotten filter cannot break.

The four factory modules are the recipe for the fifth. Adding one means creating modules/<name> in the packages that need it, declaring a port if the platform has to trigger something, and letting the guards say whether it fits. What used to be a code review conversation is now a red check.

What is left is honest and written down: the UI copy is hardcoded in Spanish with no i18n, the job runner has a single execution adapter, and a public per tenant site is what would give @acme/domains the consumer it does not have yet.