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.
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
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.
bashgit 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).
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:
prismamodel 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.
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.
Three rules I learned the hard way:
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
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:
bashpnpm 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());
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.