Recipe: Multi-Tenant SaaS
This recipe walks through building a complete multi-tenant SaaS application using CruzJS. You will set up organization-scoped data, per-org billing, member management, and access control.
Architecture Overview
Section titled “Architecture Overview”In a CruzJS multi-tenant app:
- Tenant = Organization — Each customer is an organization
- Data isolation — All queries are scoped to
orgId - Billing — Stripe subscriptions are per organization
- Access control — Role-based permissions per member
User A ──┬── Org 1 (Owner) ──── Projects, Members, Billing └── Org 2 (Member) ──── Projects, Members, Billing
User B ──── Org 1 (Admin) ──── Projects, Members, BillingStep 1: Project Setup
Section titled “Step 1: Project Setup”npx create-cruz-app my-saas --with-procd my-saaspnpm installcruz devThe --with-pro flag includes @cruzjs/pro which provides billing and admin features. Organizations, members, roles, and permissions are included in @cruzjs/start.
Step 2: Define Tenant-Scoped Tables
Section titled “Step 2: Define Tenant-Scoped Tables”Every business data table includes an orgId column for tenant isolation:
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';import { createId } from '@paralleldrive/cuid2';
const generateId = () => createId();const nowISO = () => new Date().toISOString();
export const projects = sqliteTable('Project', { id: text('id').primaryKey().$defaultFn(generateId), name: text('name').notNull(), description: text('description'), orgId: text('orgId').notNull(), // Tenant isolation status: text('status').notNull().default('active'), createdBy: text('createdBy').notNull(), createdAt: text('createdAt').notNull().$defaultFn(nowISO), updatedAt: text('updatedAt').notNull().$defaultFn(nowISO),}, (table) => ({ orgIdIdx: index('Project_orgId_idx').on(table.orgId),}));
export const tasks = sqliteTable('Task', { id: text('id').primaryKey().$defaultFn(generateId), title: text('title').notNull(), projectId: text('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }), orgId: text('orgId').notNull(), // Denormalized for query efficiency assigneeId: text('assigneeId'), status: text('status').notNull().default('todo'), createdAt: text('createdAt').notNull().$defaultFn(nowISO), updatedAt: text('updatedAt').notNull().$defaultFn(nowISO),}, (table) => ({ orgIdIdx: index('Task_orgId_idx').on(table.orgId), projectIdIdx: index('Task_projectId_idx').on(table.projectId),}));
// Re-export framework tablesexport * from '@cruzjs/start/database/schema';Step 3: Create Org-Scoped Services
Section titled “Step 3: Create Org-Scoped Services”Services receive the orgId as a parameter and always filter by it:
import { injectable, inject } from 'inversify';import { DRIZZLE, type DrizzleDatabase } from '@cruzjs/core/shared/database/drizzle.service';import { projects } from '../../database/schema';import { eq, and, desc } from 'drizzle-orm';
@injectable()export class ProjectService { constructor(@inject(DRIZZLE) private readonly db: DrizzleDatabase) {}
// Every method requires orgId for tenant isolation async list(orgId: string) { return this.db .select() .from(projects) .where(eq(projects.orgId, orgId)) .orderBy(desc(projects.createdAt)); }
async getById(orgId: string, projectId: string) { const [project] = await this.db .select() .from(projects) .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))) .limit(1); return project ?? null; }
async create(orgId: string, userId: string, input: { name: string; description?: string }) { const [project] = await this.db .insert(projects) .values({ name: input.name, description: input.description ?? null, orgId, createdBy: userId, }) .returning(); return project; }}Step 4: tRPC Routers with orgProcedure
Section titled “Step 4: tRPC Routers with orgProcedure”Use orgProcedure to enforce that every request has a valid org context:
import { orgProcedure, router } from '@cruzjs/core/trpc/context';import { z } from 'zod';import { ProjectService } from './project.service';
export const projectRouter = router({ list: orgProcedure.query(async ({ ctx }) => { // ctx.org.orgId is guaranteed to be a valid org the user belongs to return ctx.container.get(ProjectService).list(ctx.org.orgId); }),
create: orgProcedure .input(z.object({ name: z.string().min(1), description: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.container.get(ProjectService).create( ctx.org.orgId, ctx.session.user.id, input ); }),});The orgProcedure middleware:
- Verifies the user is authenticated
- Resolves the current organization from the session
- Verifies the user is a member of that organization
- Makes
ctx.org.orgIdandctx.org.roleavailable
Step 5: Configure Billing
Section titled “Step 5: Configure Billing”Define plans in your config:
export default defineConfig({ billing: { defaultPlans: [ { id: 'starter', name: 'Starter', price: 0, interval: 'month', isUpgradeable: true, features: ['3 projects', '2 members', 'Community support'], limits: { maxProjects: 3, maxMembers: 2 }, }, { id: 'pro', name: 'Pro', price: 2900, interval: 'month', stripePriceId: 'price_pro_monthly', isUpgradeable: true, features: ['Unlimited projects', '10 members', 'Email support'], limits: { maxProjects: -1, maxMembers: 10 }, }, { id: 'enterprise', name: 'Enterprise', price: 9900, interval: 'month', stripePriceId: 'price_enterprise_monthly', isUpgradeable: false, features: ['Everything in Pro', 'Unlimited members', 'Priority support'], limits: { maxProjects: -1, maxMembers: -1 }, }, ], upgradeRules: { starter: ['pro', 'enterprise'], pro: ['enterprise'], }, },});Enforce Plan Limits
Section titled “Enforce Plan Limits”@injectable()export class ProjectService { constructor( @inject(DRIZZLE) private readonly db: DrizzleDatabase, @inject(BillingService) private readonly billing: BillingService, ) {}
async create(orgId: string, userId: string, input: CreateProjectInput) { // Check plan limits before creating const subscription = await this.billing.getSubscription(orgId); const plan = await this.billing.getPlan(subscription?.planId ?? 'starter'); const existingCount = await this.countProjects(orgId);
if (plan?.limits?.maxProjects !== -1 && existingCount >= (plan?.limits?.maxProjects ?? 3)) { throw new Error(`Plan limit reached. Upgrade to create more projects.`); }
return this.db.insert(projects).values({ ...input, orgId, createdBy: userId }).returning(); }}Step 6: Member Management
Section titled “Step 6: Member Management”Add members via the invitation flow:
export const memberRouter = router({ invite: orgProcedure .input(z.object({ email: z.string().email(), role: z.enum(['ADMIN', 'MEMBER', 'VIEWER']), })) .mutation(async ({ ctx, input }) => { // Check permission const permService = ctx.container.get(PermissionService); await permService.checkPermission(ctx.session.user.id, ctx.org.orgId, 'member:write');
// Check member limit const members = await ctx.container.get(MemberService).listMembers(ctx.org.orgId); const subscription = await ctx.container.get(BillingService).getSubscription(ctx.org.orgId); // ... check limits
const invitationService = ctx.container.get(InvitationService); return invitationService.createInvitation( ctx.org.orgId, { email: input.email, role: input.role }, ctx.session.user.id ); }),});Step 7: Org-Scoped URLs
Section titled “Step 7: Org-Scoped URLs”Use slug-based routing for all org pages:
/orgs/:slug/dashboard/orgs/:slug/projects/orgs/:slug/projects/:projectId/orgs/:slug/members/orgs/:slug/billing/orgs/:slug/settingsThe org slug is resolved in the route middleware and the org context is available throughout the request.
Step 8: Audit Everything
Section titled “Step 8: Audit Everything”Track all mutations for compliance and debugging:
async create(orgId: string, userId: string, input: CreateProjectInput) { const project = await this.db.insert(projects).values({ ... }).returning();
await this.auditLog.logAudit( orgId, userId, 'created', 'project', { projectId: project[0].id, projectName: project[0].name } );
return project[0];}Data Isolation Checklist
Section titled “Data Isolation Checklist”Before shipping your multi-tenant app, verify:
- Every data table has an
orgIdcolumn (or is joined through one that does) - Every query includes
where(eq(table.orgId, orgId)) - All tRPC mutations use
orgProcedure(neverpublicProcedurefor tenant data) - File uploads include org context in the storage key (
uploads/${orgId}/...) - API keys are scoped to organizations
- Audit logs record the org context
- Admin endpoints are separate from tenant endpoints
Next Steps
Section titled “Next Steps”- Organizations — OrgService deep dive
- Billing — Stripe integration details
- Permissions — Fine-grained access control
- CRUD Feature Recipe — Build features step by step