How to Convert a Single-Tenant Boilerplate to Multi-Tenant (2026)
TL;DR
Most boilerplates are user-centric (single-tenant). B2B SaaS needs to be organization-centric (multi-tenant). The conversion requires: adding an Organization model, scoping all data queries to an organization, migrating existing data, and updating auth context. Budget 3-5 days depending on how many models you have. The hardest part is data migration, not the schema.
Understanding the Difference
Single-tenant (before):
User → Projects
User → Invoices
User → Settings
Multi-tenant (after):
User → OrganizationMember → Organization → Projects
→ Invoices
→ Settings
Data belongs to the organization, not the user. Users are members of organizations.
Step 1: Add Organization Models
// prisma/schema.prisma — new models to add
model Organization {
id String @id @default(cuid())
name String
slug String @unique
plan String @default("free")
createdAt DateTime @default(now())
members OrganizationMember[]
invitations Invitation[]
// Add your existing models here:
projects Project[]
invoices Invoice[]
}
model OrganizationMember {
id String @id @default(cuid())
organizationId String
userId String
role String @default("member") // owner, admin, member
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@index([userId])
}
Step 2: Update Existing Models
Add organizationId to every model that currently has userId:
// Before:
model Project {
id String @id @default(cuid())
userId String
name String
user User @relation(fields: [userId], references: [id])
}
// After:
model Project {
id String @id @default(cuid())
organizationId String // New field
userId String // Keep for "created by" attribution
name String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User @relation(fields: [userId], references: [id])
@@index([organizationId])
}
Step 3: Data Migration
// prisma/migrations/manual/migrate-to-multi-tenant.ts
// Run ONCE after applying schema migration
async function migrateToMultiTenant() {
const users = await prisma.user.findMany({
include: {
projects: true,
invoices: true,
},
});
for (const user of users) {
// Create a personal organization for each existing user
const org = await prisma.organization.create({
data: {
name: user.name ?? user.email.split('@')[0],
slug: slugify(user.email.split('@')[0]) + '-' + user.id.slice(-6),
members: {
create: {
userId: user.id,
role: 'owner',
},
},
},
});
// Move all their data to the organization
await prisma.project.updateMany({
where: { userId: user.id },
data: { organizationId: org.id },
});
await prisma.invoice.updateMany({
where: { userId: user.id },
data: { organizationId: org.id },
});
console.log(`Migrated user ${user.email} to org ${org.slug}`);
}
}
migrateToMultiTenant().then(() => console.log('Migration complete'));
Step 4: Organization Context in Auth
// lib/session.ts — extend session with active organization
import { getServerSession } from 'next-auth';
import { prisma } from './prisma';
export type SessionWithOrg = {
user: {
id: string;
email: string;
name?: string;
};
organization: {
id: string;
slug: string;
name: string;
plan: string;
role: string; // User's role in this org
};
};
export async function getSessionWithOrg(): Promise<SessionWithOrg | null> {
const session = await getServerSession();
if (!session?.user?.id) return null;
// Get active org from cookie or DB (first org if not set)
const membership = await prisma.organizationMember.findFirst({
where: { userId: session.user.id },
include: { organization: true },
orderBy: { createdAt: 'asc' }, // Primary org first
});
if (!membership) return null;
return {
user: {
id: session.user.id,
email: session.user.email!,
name: session.user.name ?? undefined,
},
organization: {
id: membership.organization.id,
slug: membership.organization.slug,
name: membership.organization.name,
plan: membership.organization.plan,
role: membership.role,
},
};
}
Step 5: Scope All Queries to Organization
This is the critical security step. Every data query must include organizationId:
// Before (INSECURE for multi-tenant):
const projects = await prisma.project.findMany({
where: { userId: session.user.id },
});
// After (CORRECT for multi-tenant):
const session = await getSessionWithOrg();
const projects = await prisma.project.findMany({
where: { organizationId: session.organization.id },
});
Utility to prevent forgetting:
// lib/db.ts — always-scoped query helpers
export function getOrgDb(organizationId: string) {
return {
projects: {
findMany: (args?: Omit<Prisma.ProjectFindManyArgs, 'where'> & { where?: Omit<Prisma.ProjectWhereInput, 'organizationId'> }) =>
prisma.project.findMany({
...args,
where: { ...args?.where, organizationId },
}),
},
// Add other models...
};
}
// Usage — organizationId is always included:
const orgDb = getOrgDb(session.organization.id);
const projects = await orgDb.projects.findMany();
Step 6: Organization Switcher
When users belong to multiple organizations:
// components/OrgSwitcher.tsx
export function OrgSwitcher({ currentOrg, orgs }: {
currentOrg: { id: string; name: string; slug: string };
orgs: { id: string; name: string; slug: string }[];
}) {
return (
<select
value={currentOrg.id}
onChange={(e) => {
const selected = orgs.find(o => o.id === e.target.value);
if (selected) {
// Set active org cookie and reload
document.cookie = `activeOrg=${selected.id}; path=/`;
window.location.reload();
}
}}
className="text-sm border border-gray-200 rounded px-2 py-1"
>
{orgs.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
);
}
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|---|---|
| Querying by userId instead of orgId | Data leaks between orgs | Always query by organizationId |
| Forgetting to migrate billing data | Users lose subscription | Include subscriptions in migration |
| No role checks on mutations | Members can delete org data | Check role on every write operation |
| Invitations expire but not cleaned | DB bloat | Add expiry + cron cleanup |
| Stripe customer per user (not org) | Billing breaks for teams | Migrate Stripe customers to org level |
Find multi-tenant boilerplates that skip this migration on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →