Single-Tenant to Multi-Tenant Boilerplate 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.
When to Make This Conversion
Not every product needs multi-tenancy. The conversion adds meaningful complexity and development time. The signals that it's time:
A B2C product with solo users has no reason to become multi-tenant unless team collaboration is on the product roadmap. A recipe app, a personal journal, or a habit tracker serves individual users who don't need to share data with teammates. Adding multi-tenancy to these products creates organizational overhead (org creation, member invitation, role management) that most users will never use.
A B2B product serving companies — rather than individuals — almost certainly needs multi-tenancy. The moment a business customer asks "can I invite my colleague?", the product needs team support. The moment a business says "we want one invoice for the whole company, not separate subscriptions for each employee," billing needs to be at the organization level.
The cost of retrofitting multi-tenancy after significant user data exists is high. It requires a data migration script that creates personal organizations for every existing user and updates every resource record with the new organizationId. Plan for multi-tenancy at the start if your product's target customer is a business.
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();
Handling Soft Multi-Tenancy for Existing Products
If you have an existing product with significant user data and can't afford downtime for migration, a soft multi-tenancy approach lets you add organization support incrementally without a hard data migration:
Phase 1: Add Organization models alongside existing User models. Create organizations lazily — the first time a user tries to invite a teammate, their personal organization is created automatically. All their existing data is retroactively assigned to this personal org in the background (via a queue processor, not a blocking migration).
Phase 2: Update the UI to show org context (org switcher, member list) but allow the old user-centric experience to continue working. New data created after Phase 1 goes into the org; old data is migrated to the personal org in the background.
Phase 3: Once all data has been migrated to orgs (verify with the validation script from the Migration Validation section), make organizationId non-nullable and remove the old user-scoped query paths.
This approach trades migration duration (weeks instead of hours) for zero downtime. For products with 10K+ users and active data, this is the correct path. For smaller products or pre-launch, the direct migration approach in this guide is simpler.
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>
);
}
Invitation System
After the multi-tenant migration, the most-requested feature is inviting teammates. The invitation flow: admin enters email → invitation record created → email sent → recipient clicks link → account created or linked → added to org.
model Invitation {
id String @id @default(cuid())
organizationId String
email String
role String @default("member")
token String @unique @default(cuid())
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([token])
@@index([email])
}
// app/api/invitations/route.ts
export async function POST(req: Request) {
const session = await getSessionWithOrg();
if (!session || !['owner', 'admin'].includes(session.organization.role)) {
return Response.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { email, role = 'member' } = await req.json();
// Check if already a member
const existingMember = await prisma.user.findFirst({
where: {
email,
organizationMembers: { some: { organizationId: session.organization.id } },
},
});
if (existingMember) {
return Response.json({ error: 'Already a member' }, { status: 409 });
}
const invitation = await prisma.invitation.create({
data: {
organizationId: session.organization.id,
email,
role,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
await sendInvitationEmail({
to: email,
orgName: session.organization.name,
invitedBy: session.user.email,
inviteUrl: `${process.env.NEXTAUTH_URL}/invite/${invitation.token}`,
});
return Response.json({ success: true });
}
The accept endpoint creates or links the invitee's account and adds them to the organization. Invitations expire after 7 days and should be cleaned up by a weekly cron job (prisma.invitation.deleteMany({ where: { expiresAt: { lt: new Date() }, acceptedAt: null } })).
Migration Validation
Running the data migration without validation is risky. After the migration script completes, run these consistency checks:
// Verify all resources have been migrated
async function validateMigration() {
const unmigrated = await prisma.project.count({
where: { organizationId: null },
});
if (unmigrated > 0) {
console.error(`ERROR: ${unmigrated} projects without organizationId`);
process.exit(1);
}
const orphanedMembers = await prisma.organizationMember.count({
where: { organization: null },
});
if (orphanedMembers > 0) {
console.error(`ERROR: ${orphanedMembers} members without organization`);
process.exit(1);
}
console.log('Migration validated successfully');
}
Run this check immediately after migration before removing the organizationId nullable constraint from your schema. Once you've confirmed zero unmigrated records, add @default and make the field required in a follow-up migration.
Organization Settings and Customization
Once the data model is multi-tenant, organizations need their own settings page. The most common org-level settings:
Organization profile: Name, slug, logo upload, description. The slug is used in URLs (/org/acme-inc/dashboard) and should be validated as unique and URL-safe. Allow slug changes but implement redirects from old slugs so shared links don't break.
Domain-based auto-join: Enterprise customers often want anyone with a company email address (ending in @company.com) to automatically be added to the organization. This requires email domain verification — confirm that the organization admin actually controls that domain by sending a verification email to an address at that domain.
Organization deletion: Deleting an organization is a high-stakes, irreversible action. Require a typed confirmation ("Type the organization name to confirm"), notify all members via email, and delay the actual deletion by 24 hours with a cancellation link. This prevents accidental deletions and gives members time to export their data.
// app/settings/organization/danger-zone/actions.ts
export async function scheduleOrganizationDeletion(orgId: string, userId: string) {
await requireOrgRole(userId, orgId, 'owner');
// Schedule deletion for 24 hours from now
const deletionAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.organization.update({
where: { id: orgId },
data: { scheduledDeletionAt: deletionAt },
});
// Notify all members
const members = await prisma.organizationMember.findMany({
where: { organizationId: orgId },
include: { user: true },
});
await Promise.all(members.map(m =>
sendOrgDeletionWarning(m.user.email, orgId, deletionAt)
));
}
Testing Data Isolation Post-Migration
After migration, test that the multi-tenant isolation is correct with an automated test:
// test/data-isolation.test.ts
test('org A cannot access org B data', async () => {
const orgA = await createTestOrg();
const orgB = await createTestOrg();
const projectInOrgA = await createTestProject({ organizationId: orgA.id });
// API request authenticated as org B member
const response = await apiClient
.asOrgMember(orgB)
.get(`/api/projects/${projectInOrgA.id}`);
expect(response.status).toBe(404);
});
This test should pass before any production deployment of your multi-tenant conversion.
Organization-Level Feature Gating
After the migration, billing and feature gating moves to the organization level. An organization's plan determines what all its members can access — not the individual user's subscription.
// lib/org-permissions.ts
export async function orgHasFeature(organizationId: string, feature: string): Promise<boolean> {
const org = await prisma.organization.findUnique({
where: { id: organizationId },
select: { plan: true, memberCount: true },
});
if (!org) return false;
const ORG_PLAN_FEATURES: Record<string, string[]> = {
free: ['basic_dashboard', 'up_to_5_members'],
pro: ['basic_dashboard', 'up_to_25_members', 'api_access', 'advanced_analytics'],
enterprise: ['*'], // All features
};
const features = ORG_PLAN_FEATURES[org.plan] ?? [];
// Special case: member count limits
if (feature === 'invite_member') {
const maxMembers = { free: 5, pro: 25, enterprise: Infinity };
const limit = maxMembers[org.plan as keyof typeof maxMembers] ?? 5;
return org.memberCount < limit;
}
return features.includes('*') || features.includes(feature);
}
// Usage in invitation flow:
const canInvite = await orgHasFeature(session.organization.id, 'invite_member');
if (!canInvite) {
return Response.json({
error: 'Member limit reached',
upgradeRequired: true,
}, { status: 402 });
}
Per-seat billing (common in B2B SaaS) is a natural extension: orgHasFeature becomes orgHasSeatAvailable, and the Stripe subscription quantity tracks the number of active members. When a member is added, increment the subscription quantity; when removed, decrement it.
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 |
Role-Based Access Control
After adding multi-tenancy, the next requirement is almost always RBAC — not every member of an organization should be able to do everything. The standard three-role model (owner, admin, member) covers most SaaS use cases:
Owner: Full access, billing control, can delete the organization and invite/remove any member. Typically one per org (the person who created it), though some products allow multiple owners.
Admin: Manage members, content, and settings. Cannot cancel the subscription or delete the org. The right level for team leads who need operational control without financial authority.
Member: Can create and edit content scoped to the organization, but cannot manage other members or change organization settings. This is the default for invited teammates.
Enforcing RBAC on the server requires checking the user's role before every mutating operation. The pattern:
// lib/permissions.ts
export async function requireOrgRole(
userId: string,
organizationId: string,
minimumRole: 'member' | 'admin' | 'owner'
) {
const membership = await prisma.organizationMember.findFirst({
where: { userId, organizationId },
});
if (!membership) {
throw new Error('Not a member of this organization');
}
const roleHierarchy = { member: 1, admin: 2, owner: 3 };
if (roleHierarchy[membership.role as keyof typeof roleHierarchy] < roleHierarchy[minimumRole]) {
throw new Error(`Requires ${minimumRole} role`);
}
return membership;
}
// Usage in API routes:
// DELETE /api/organizations/:id — only owners can delete
export async function deleteOrganization(orgId: string, userId: string) {
await requireOrgRole(userId, orgId, 'owner');
await prisma.organization.delete({ where: { id: orgId } });
}
// POST /api/invitations — admins and owners can invite
export async function inviteMember(orgId: string, inviterUserId: string, email: string) {
await requireOrgRole(inviterUserId, orgId, 'admin');
// Create invitation...
}
The critical place this is enforced is in the UI — show or hide controls based on the user's role — but the real enforcement is server-side. Client-side role checks are UX; server-side role checks are security.
Migrating Stripe Customers to Organization Level
The most overlooked step in the multi-tenant migration is billing. If your Stripe integration was built user-centric, each user has a Stripe customer record, and subscriptions are tied to individual users. After the migration, billing needs to be at the organization level.
Why this matters: when a user invites teammates to their org, you don't want to charge each teammate separately for the same subscription. And when an organization-level invoice is generated, it should go to one Stripe customer representing the org, not to whoever signed up first.
The migration script extension:
// Add to migrateToMultiTenant() after creating org:
if (user.stripeCustomerId) {
// Update Stripe customer metadata to reference the org
await stripe.customers.update(user.stripeCustomerId, {
metadata: { organizationId: org.id },
});
// Move the customer ID to the org
await prisma.organization.update({
where: { id: org.id },
data: { stripeCustomerId: user.stripeCustomerId },
});
// Clear from user (billing is now org-level)
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: null },
});
}
After this migration, every billing webhook, checkout session, and subscription management action references the organization's Stripe customer, not the individual user. Future teammates who join the org inherit the organization's subscription without creating new Stripe customers.
Related Resources
For the full team management implementation including RBAC, invitation system, and per-seat billing, team and org management in SaaS boilerplates covers the complete post-migration feature set. For boilerplates that start multi-tenant from day one (avoiding this migration entirely), best boilerplates for multi-tenant SaaS covers Makerkit, Supastarter, and other org-first starters. For the data isolation testing patterns that catch cross-tenant bugs before they hit production, best boilerplates for internal tools covers the RBAC testing approaches.
Methodology
Migration patterns derived from real-world multi-tenant conversion case studies in the ShipFast Discord and T3 community forum. Data migration script patterns verified against Prisma documentation for updateMany and transaction handling. The migration path from single-tenant to multi-tenant has become more predictable as community documentation around Prisma RLS patterns has matured — teams tackling this in 2026 benefit from substantially better reference material than was available in 2023.
Find multi-tenant boilerplates that skip this migration on StarterPick.
Check out this boilerplate
View ShipFaston StarterPick →