← Back to Software Vertical
Free ModuleMODULE 01 · 3h · Software Vertical

Schema & Multi-Tenancy

Most multi-tenant SaaS apps leak data because the schema makes it easy to. This module shows you the schema that makes it structurally impossible — pulled directly from VitrOS, the SaaS I shipped from empty repo to paying customer in 30 days.

Video Walkthrough

Loom recording dropping this week.

What you'll have when you're done

A Prisma schema that prevents cross-tenant data leaks by construction

A Membership table that links Users to Workspaces with roles

Scoped query helpers so every query is workspace-aware

A migration strategy that won't blow up your data later

Seed data so you can demo the app within 5 minutes of cloning

STEP 01

Clone the starter

The repo is a stripped-down version of the schema layer used in VitrOS. Next.js 16, Prisma, Postgres. Nothing else — no auth, no payments yet. We'll add those in Modules 02 and 04.

bash
git clone https://github.com/theblockchainbaby/yorksims-software-01-schema cd yorksims-software-01-schema pnpm install

You need Node 20+, pnpm, and a Postgres database. The README has instructions for both local Postgres and Neon (free tier).

STEP 02

The schema that prevents cross-tenant leaks

Most tutorials hand you a User table and call it multi-tenant because there's a `workspaceId` column on everything. That works until the day a developer forgets the where clause. Here's the shape that makes that impossible:

prisma
model Workspace { id String @id @default(cuid()) slug String @unique name String members Membership[] projects Project[] createdAt DateTime @default(now()) } model User { id String @id @default(cuid()) email String @unique memberships Membership[] createdAt DateTime @default(now()) } model Membership { id String @id @default(cuid()) user User @relation(fields: [userId], references: [id]) userId String workspace Workspace @relation(fields: [workspaceId], references: [id]) workspaceId String role Role @default(MEMBER) createdAt DateTime @default(now()) @@unique([userId, workspaceId]) @@index([workspaceId]) } enum Role { OWNER ADMIN MEMBER } model Project { id String @id @default(cuid()) workspace Workspace @relation(fields: [workspaceId], references: [id]) workspaceId String name String createdAt DateTime @default(now()) @@index([workspaceId]) }

Two things to notice. First, Membership is the join — Users don't belong to Workspaces directly. Second, every tenant-scoped table (Project, and later: Task, Document, Invoice…) gets a workspaceId with an index. That index is non-negotiable. Without it, your queries scan the whole table once you have real customers.

STEP 03

Scoped query helpers

The way you stop yourself from forgetting the workspaceId filter is to never write a query without it. Wrap Prisma in a thin scoped client:

typescript
// lib/db.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); export function scopedDb(workspaceId: string) { return { project: { findMany: (args: Omit<Parameters<typeof prisma.project.findMany>[0], "where"> & { where?: Parameters<typeof prisma.project.findMany>[0]["where"]; } = {}) => prisma.project.findMany({ ...args, where: { ...args.where, workspaceId }, }), create: (data: Omit<Parameters<typeof prisma.project.create>[0]["data"], "workspaceId">) => prisma.project.create({ data: { ...data, workspaceId }, }), }, // ...repeat for every tenant-scoped model }; } export { prisma };

Usage: scopedDb(workspaceId).project.findMany() instead of prisma.project.findMany(). The compiler now refuses to let you write the unsafe version.

STEP 04

Migrations that won't blow up later

Three rules I learned the hard way:

  • Never edit a migration after it's deployed. Add a new one instead.
  • Make additive changes safe by default. New columns get a default value or are nullable. Adding NOT NULL to an existing column needs a backfill migration first.
  • Renames are two migrations, not one. Add the new column, copy data, deploy. Drop the old column, deploy. Otherwise old-version pods break during a rolling deploy.
bash
# Generate a new migration after editing schema.prisma pnpm prisma migrate dev --name add_projects # Apply pending migrations in production pnpm prisma migrate deploy
STEP 05

Seed data so the app works on first clone

The repo ships with a seed that creates a demo workspace, a demo user, and a handful of projects so you can run the app immediately:

bash
pnpm prisma db seed
typescript
// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const ws = await prisma.workspace.create({ data: { slug: "demo", name: "Demo Workspace" }, }); const user = await prisma.user.create({ data: { email: "demo@yorksims.com" }, }); await prisma.membership.create({ data: { userId: user.id, workspaceId: ws.id, role: "OWNER" }, }); await prisma.project.createMany({ data: [ { workspaceId: ws.id, name: "Onboarding" }, { workspaceId: ws.id, name: "Marketing site" }, { workspaceId: ws.id, name: "Q3 launches" }, ], }); } main().finally(() => prisma.$disconnect());

What's next

You now have a multi-tenant schema you can’t accidentally leak data across. Modules 02-06 build the rest of VitrOS — auth, the app shell, Stripe payments, the PWA layer, the admin panel — and they’re free too, dropping over the coming weeks. Pro ($29/mo) is for the monthly live Q&A and the private community; Builder ($499/mo) adds direct email access and small-group coaching.