SaaS Boilerplate Multi-Tenancy: Get the Architecture Right Before Launch
Most indie hackers bolt multi-tenancy on after launch — and they all regret it. I've been there. Retrofitting tenant isolation into a production database is the kind of refactor that costs you a long weekend and your sanity, while real users sit on the other side hoping their data doesn't bleed into someone else's account. Here's how to build a SaaS boilerplate with multi-tenancy from day one, the way I structured it in BetterStarter.
Why Multi-Tenancy Needs to Come First
You're building a SaaS. That means multiple companies, teams, or accounts sharing your infrastructure. Multi-tenancy is how you keep their data isolated. Skip it now and you'll add it later — under deadline pressure, with real customers in your database, running migrations that terrify you.
There are three standard approaches:
| Approach | Isolation | Cost | Complexity | Best For |
|---|---|---|---|---|
| Row-level (shared DB) | Low | Cheapest | Simple | Early-stage SaaS |
| Schema-per-tenant | Medium | Medium | Moderate | Mid-market |
| Database-per-tenant | High | Expensive | High | Enterprise / compliance |
For indie hackers and early-stage startups, row-level tenancy is the right call. One database, one schema, every row tagged with a tenant_id. Cheap to run, simple to query, and if you ever need to move to schema-per-tenant for compliance, you'll have a clear migration path because the data is already logically separated.
The Drizzle ORM Schema You Actually Need
With Drizzle ORM, setting up row-level multi-tenancy is clean and type-safe. Here's the pattern I use:
// schema/tenants.ts
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
plan: text("plan").notNull().default("free"),
stripeCustomerId: text("stripe_customer_id"),
subscriptionStatus: text("subscription_status").default("inactive"),
createdAt: timestamp("created_at").defaultNow(),
});
export const tenantMembers = pgTable("tenant_members", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role").notNull().default("member"), // "owner" | "admin" | "member"
joinedAt: timestamp("joined_at").defaultNow(),
});
// Every data table gets tenantId — no exceptions
export const projects = pgTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
The golden rule: every resource table has a tenant_id column. No exceptions. It's tempting to skip it for tables that "don't need it yet" — don't. You'll add it later, under pressure, with a migration that terrifies you.
Enforcing Tenant Isolation in TanStack Start
Schema is half the battle. The other half is making sure your server functions never return data from the wrong tenant. In TanStack Start, I handle this with a tenant context helper called on every protected route:
// lib/tenant-context.ts
import { db } from "@/db";
import { tenants, tenantMembers } from "@/db/schema";
import { eq, and } from "drizzle-orm";
export async function getTenantContext(userId: string, tenantSlug: string) {
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.slug, tenantSlug),
});
if (!tenant) throw new Error("Tenant not found");
const membership = await db.query.tenantMembers.findFirst({
where: and(
eq(tenantMembers.userId, userId),
eq(tenantMembers.tenantId, tenant.id)
),
});
if (!membership) throw new Error("Unauthorized: not a member of this tenant");
return { tenant, role: membership.role };
}
// In your server function:
export const getProjects = createServerFn({ method: "GET" }).handler(
async ({ context }) => {
const { tenant } = await getTenantContext(
context.user.id,
context.params.tenantSlug
);
// Always scoped — cross-tenant leaks are structurally impossible
return db.query.projects.findMany({
where: eq(projects.tenantId, tenant.id),
});
}
);
Tenant isolation is enforced at the query level, not scattered across if statements in your application logic. One function, always called, always scoped. This is the pattern I baked into BetterStarter so you don't have to think about it.
The Architecture at a Glance
graph TD
A[User Request] --> B[TanStack Start Server Fn]
B --> C{Session Valid?}
C -->|No| D[401 Unauthorized]
C -->|Yes| E[getTenantContext]
E -->|Not a member| F[403 Forbidden]
E -->|Member verified| G[Scoped DB Query]
G --> H[tenant_id = tenant.id]
H --> I[Return Tenant Data Only]
Every request flows through auth, then tenant membership verification, then a scoped query. No shortcuts, no bypasses. The architecture makes cross-tenant leaks structurally difficult rather than relying on developer discipline.
Auth, Invitations, and Team Growth
Multi-tenancy isn't just data isolation — it's people. When someone invites a teammate, you need: an invite record with an expiry token, an email sent via your transactional provider, and a join flow that validates the token and inserts a tenant_members row.
Better-Auth handles user sessions independently from tenant membership. This matters — a user can belong to multiple tenants, and switching between them is just loading a different tenantSlug from the URL. The session stays the same; the tenant context changes. See the full TanStack Start auth setup guide for how Better-Auth sessions are structured.
Billing Per Tenant with Stripe
Billing attaches to tenants, not users. Every tenant gets a Stripe Customer ID — store it on the tenants table (already in the schema above). When a webhook fires, you look up the tenant by stripeCustomerId and update their subscriptionStatus and plan. Every member of that tenant immediately gets the upgraded access — no per-user billing logic, no edge cases.
The full pattern for Stripe webhooks in TanStack Start covers how to verify the webhook signature and handle the tenant lookup safely.
FAQ
Do I need multi-tenancy if I'm building a single-user tool?
Not immediately. But if there's any chance you'll add teams or organizations later, add the tenant_id column now. It's a 10-minute schema addition today versus a painful, risky migration with live users later.
Should I use schema-per-tenant or row-level tenancy? Row-level for 95% of indie SaaS products. Schema-per-tenant adds significant operational complexity for benefits most early-stage products don't need. Only consider it when compliance requirements explicitly demand stricter isolation.
How do I handle a user belonging to multiple tenants?
Store memberships in a tenant_members join table. Read the active tenant from the URL slug. User identity and tenant context are separate concerns — keep them that way.
Is Drizzle ORM good for multi-tenant schemas?
Yes. Drizzle's TypeScript-first design means missing tenantId references show up as type errors before they become production bugs. It's one of the reasons I chose it over Prisma.
How do I prevent cross-tenant data leaks?
Always query with where: eq(table.tenantId, tenant.id) from a verified tenant context function. Never trust user-supplied tenant IDs without validating membership first. Structure makes this easier than discipline alone.
Multi-tenancy looks optional until you have your first enterprise prospect asking how you isolate their data. Get the schema right now, enforce isolation at the query layer, and you'll ship that conversation with confidence.
If you want all of this pre-wired — multi-tenant schema, Better-Auth sessions, per-tenant Stripe billing, and email invites — BetterStarter ships with it ready to go for $99 one-time.