Add Multi-Tenancy to Any SaaS Boilerplate 2026
TL;DR
Adding multi-tenancy to ShipFast (or any single-tenant boilerplate) takes 7-14 days. The work is deeper than it looks: schema changes, routing changes, permission enforcement, and billing model changes all need updating. This guide provides the implementation patterns used by Makerkit and Supastarter, adapted for adding to any boilerplate.
When to Add Multi-Tenancy vs Starting Fresh
The decision to retrofit multi-tenancy into an existing boilerplate versus starting with one that includes it (Makerkit, Supastarter) depends on how far you are into development.
Add multi-tenancy if: You have 1–3 months of custom product code built on a single-tenant boilerplate. The migration effort (7–14 days) is less than restarting on a new boilerplate and re-implementing your features.
Start fresh with a multi-tenant boilerplate if: You're at the beginning of development, or you have fewer than 2 weeks of custom code. The multi-tenant architecture assumptions (billing per org, permissions scoped to org, invitation flows) are deeply embedded in how Makerkit and Supastarter work — you get them "for free" at the cost of a learning curve.
Never add multi-tenancy reactively: Building multi-tenancy after you have production customers with data requires a data migration strategy. Their existing records need to be assigned to organizations. This is manageable, but it's extra work. If you suspect B2B is in your roadmap, design for it from day one even if you don't build it immediately — adding an organizationId column that's nullable initially is much easier than retrofitting it later.
The primary forcing function: if any of your customers will be companies with multiple users sharing an account, you need multi-tenancy. If your customers are always individuals, you don't.
Step 1: Database Schema
The foundation of multi-tenancy is the organization/team model:
// prisma/schema.prisma
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members OrganizationMember[]
invitations OrganizationInvitation[]
subscription OrgSubscription?
}
model OrganizationMember {
id String @id @default(cuid())
organizationId String
userId String
role OrgRole @default(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])
}
enum OrgRole {
OWNER
ADMIN
MEMBER
VIEWER
}
model OrganizationInvitation {
id String @id @default(cuid())
organizationId String
email String
role OrgRole @default(MEMBER)
token String @unique @default(cuid())
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
// Add to existing User model
model User {
id String @id @default(cuid())
// ... existing fields
currentOrgId String? // Track current active org
memberships OrganizationMember[]
}
After the schema change, every resource in your application needs an organizationId foreign key. This is the deep part of the migration — every table that stores per-customer data needs this field:
model Project {
id String @id @default(cuid())
organizationId String // Add this to all resource tables
organization Organization @relation(fields: [organizationId], references: [id])
// ... rest of fields
@@index([organizationId])
}
The index on organizationId is critical for query performance — every query will filter by org, so this index will be used constantly.
Step 2: Organization Creation and Selection
// lib/organizations.ts
export async function createOrganization(userId: string, name: string) {
const slug = generateSlug(name);
return prisma.organization.create({
data: {
name,
slug,
members: {
create: { userId, role: 'OWNER' }
}
}
});
}
export async function getUserOrganizations(userId: string) {
const memberships = await prisma.organizationMember.findMany({
where: { userId },
include: {
organization: {
include: { subscription: true }
}
},
orderBy: { createdAt: 'asc' }
});
return memberships.map(m => ({
...m.organization,
role: m.role,
}));
}
export async function switchOrganization(userId: string, orgId: string) {
// Verify user is actually a member
const membership = await prisma.organizationMember.findFirst({
where: { userId, organizationId: orgId }
});
if (!membership) throw new Error('Not a member of this organization');
await prisma.user.update({
where: { id: userId },
data: { currentOrgId: orgId }
});
return membership;
}
Step 3: Context in API Routes
// lib/auth-context.ts — get org context for API routes
export async function getOrgContext(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return null;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { currentOrgId: true }
});
if (!user?.currentOrgId) return null;
const membership = await prisma.organizationMember.findFirst({
where: {
userId: session.user.id,
organizationId: user.currentOrgId
},
include: { organization: true }
});
if (!membership) return null;
return {
user: session.user,
organization: membership.organization,
role: membership.role,
userId: session.user.id,
organizationId: user.currentOrgId,
};
}
// Usage in API routes — ALWAYS scope by org:
export async function GET(req: Request) {
const ctx = await getOrgContext(req);
if (!ctx) return new Response('Unauthorized', { status: 401 });
const data = await prisma.project.findMany({
where: { organizationId: ctx.organizationId }, // Always scope by org!
});
return Response.json(data);
}
The most critical rule in multi-tenant development: every query that returns per-org data must include organizationId in the WHERE clause. Missing this creates a data leak — user A can see user B's data. Enforce this at code review; consider using Prisma middleware to audit queries missing org scoping during development.
Step 4: Permission Enforcement
// lib/permissions.ts
const roleHierarchy: Record<string, number> = {
VIEWER: 0,
MEMBER: 1,
ADMIN: 2,
OWNER: 3,
};
export function hasPermission(role: string, requiredRole: string): boolean {
return (roleHierarchy[role] ?? 0) >= (roleHierarchy[requiredRole] ?? 99);
}
// Higher-order function for route protection
export async function requireOrgRole(
req: Request,
requiredRole: 'VIEWER' | 'MEMBER' | 'ADMIN' | 'OWNER'
) {
const ctx = await getOrgContext(req);
if (!ctx) return { error: 'Unauthorized', status: 401 };
if (!hasPermission(ctx.role, requiredRole)) {
return { error: 'Insufficient permissions', status: 403 };
}
return { ctx };
}
// In API routes
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const { error, status, ctx } = await requireOrgRole(req, 'ADMIN');
if (error) return new Response(error, { status });
await prisma.project.delete({
where: {
id: params.id,
organizationId: ctx.organizationId, // Always verify ownership
}
});
return Response.json({ success: true });
}
Step 5: Invitation System
// lib/invitations.ts
export async function inviteMember(
organizationId: string,
email: string,
role: 'ADMIN' | 'MEMBER' | 'VIEWER' = 'MEMBER'
) {
// Check if already a member
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
const existingMembership = await prisma.organizationMember.findFirst({
where: { userId: existingUser.id, organizationId }
});
if (existingMembership) throw new Error('Already a member');
}
// Delete any existing invitation for this email
await prisma.organizationInvitation.deleteMany({
where: { email, organizationId }
});
const invitation = await prisma.organizationInvitation.create({
data: {
organizationId,
email,
role,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
include: { organization: true }
});
// Send invitation email
await sendInvitationEmail({
email,
organizationName: invitation.organization.name,
inviteUrl: `${process.env.NEXTAUTH_URL}/invitations/${invitation.token}`,
});
return invitation;
}
export async function acceptInvitation(token: string, userId: string) {
const invitation = await prisma.organizationInvitation.findFirst({
where: { token, expiresAt: { gt: new Date() } }
});
if (!invitation) throw new Error('Invalid or expired invitation');
await prisma.organizationMember.create({
data: {
organizationId: invitation.organizationId,
userId,
role: invitation.role,
}
});
await prisma.organizationInvitation.delete({ where: { id: invitation.id } });
await prisma.user.update({
where: { id: userId },
data: { currentOrgId: invitation.organizationId }
});
}
Step 6: URL-Based Organization Routing (Optional)
// For apps with org-specific URLs: app.saas.com/[orgSlug]/dashboard
// app/[orgSlug]/layout.tsx
export default async function OrgLayout({
children,
params,
}: {
children: React.ReactNode;
params: { orgSlug: string };
}) {
const session = await getServerSession(authOptions);
if (!session) redirect('/login');
const org = await prisma.organization.findFirst({
where: {
slug: params.orgSlug,
members: { some: { userId: session.user.id } }
}
});
if (!org) notFound();
return (
<OrgProvider organization={org}>
<OrgHeader org={org} />
<main>{children}</main>
</OrgProvider>
);
}
Billing for Multi-Tenant Products
Multi-tenancy changes your billing model. Instead of billing per user, you bill per organization — one Stripe subscription per org. The org owner is the billing contact.
The pattern for org-level billing:
- Create a Stripe Customer when an org is created
- Attach the Stripe Customer ID to the Organization record
- Stripe subscriptions belong to the org, not the individual user
- Only OWNER and ADMIN roles can access billing settings
When a user leaves an organization, their data stays in the org. When an org cancels their subscription, all org members lose access — not just the person who cancelled.
Per-seat billing (charge more as the team grows) requires tracking member counts and updating the Stripe subscription quantity. Stripe supports quantity on subscription items — update it whenever members join or leave.
Common Multi-Tenancy Mistakes
Forgetting organizationId on new tables: Every time you add a new Prisma model for per-org data, the first column should be organizationId. It's easy to forget this when adding features quickly.
Not enforcing org scoping in mutations: Read queries often get the org scoping added, but UPDATE and DELETE queries sometimes miss it. An unscoped DELETE query is a critical security vulnerability.
Conflating personal accounts with org accounts: If a user can operate in "personal mode" (no org selected) and "org mode", your permission logic becomes more complex. Consider requiring org membership for all features, with a personal org auto-created on signup.
Not handling the "last owner" case: If the last OWNER of an org tries to leave, what happens? You need to either block the action or transfer ownership first. Most multi-tenant apps block it with an error message prompting ownership transfer.
Time Budget
| Step | Duration |
|---|---|
| Database schema + migration | 1 day |
| API context middleware | 1 day |
| Permission system | 1 day |
| Invitation flow | 2 days |
| UI (org switcher, members page) | 2 days |
| Testing and edge cases | 1 day |
| Total | ~8 days |
Alternatively: choose Makerkit or Supastarter which ships multi-tenancy built-in.
Data Migration Strategy for Existing Users
Adding multi-tenancy after you have production customers is a data migration problem. Each existing user needs to be assigned to an organization. The standard migration pattern:
Create a one-time script that creates an "organization" for each existing user (a personal organization), assigns them as OWNER, and sets their currentOrgId. All their existing records get the new organizationId foreign key pointing to their personal org.
// scripts/migrate-to-multi-tenant.ts — run once against production
async function migrateExistingUsers() {
const users = await prisma.user.findMany({ where: { currentOrgId: null } });
for (const user of users) {
const org = await prisma.organization.create({
data: {
name: `${user.name || user.email}'s Workspace`,
slug: generateSlug(user.email),
members: { create: { userId: user.id, role: 'OWNER' } },
},
});
// Update the user's org pointer
await prisma.user.update({
where: { id: user.id },
data: { currentOrgId: org.id },
});
// Update all their existing resources
await prisma.project.updateMany({
where: { userId: user.id, organizationId: null },
data: { organizationId: org.id },
});
}
}
Run this migration in a transaction with a dry-run mode first. Verify row counts after migration: total projects before migration should equal total projects after migration. The migration must be idempotent — safe to run twice without creating duplicate organizations.
Routing Architecture Choices
The two main URL patterns for multi-tenant apps each have different tradeoffs.
Path-based routing (app.yourdomain.com/org/[slug]/dashboard): Simpler to implement, no DNS configuration required. Users can bookmark org-specific URLs. The org slug is visible in the URL, which lets users verify they're working in the right org. Next.js App Router handles this with a [orgSlug] route segment in your app/ directory.
Subdomain routing (acme.yourdomain.com/dashboard): More professional appearance, org isolation is clearer. Requires wildcard DNS record (*.yourdomain.com) and middleware to extract the subdomain. Harder to test locally. Best for white-label products where each org needs its own domain.
For most B2B SaaS products, path-based routing is the right default. Add subdomain routing only if your customers specifically expect it or if you're offering white-label functionality.
Related Resources
For boilerplates that include multi-tenancy out of the box, see best boilerplates for multi-tenant SaaS. If you're adding team management as a lighter implementation (without full org billing), the team and org management guide covers a simpler pattern. For white-label SaaS on top of multi-tenancy, best boilerplates for white-label SaaS adds the custom domain layer.
Choosing a Slug Strategy
Every organization needs a URL-safe slug for path-based routing. The slug appears in every URL in your app and is difficult to change once users have bookmarked or shared links containing it.
The best approach: auto-generate the slug from the org name on creation (replace spaces with hyphens, lowercase, strip special characters), then let the user edit it before finalizing. Show a preview of how the URL will look: "Your workspace URL will be: app.yourdomain.com/org/acme-corp/dashboard". Once the org is created and users have shared links, treat slug changes as a breaking change that requires a redirect from the old slug to the new one.
For enterprise customers, consider allowing custom domains instead of slugs — app.acmecorp.com instead of app.yourdomain.com/org/acme-corp. Custom domains require wildcard DNS and TLS certificates, significantly more infrastructure, but are often a sales requirement for enterprise deals.
Organization Onboarding Flow
The first-time user experience in a multi-tenant app requires a decision point that single-tenant apps don't have: should the user create an organization or join an existing one?
The standard flow: after signup and email verification, if the user has no org memberships, redirect to an org creation or join screen. Two entry points: "Create a new organization" (form: org name → slug auto-generated → create) and "Join with an invite code or link" (accept a pending invitation).
Auto-creating a personal org on signup avoids this decision entirely. The user goes straight to their personal workspace, and they can create team orgs later from a "New organization" button in the org switcher. This is the better UX for products where personal use is a valid mode — it eliminates friction for users who are evaluating the product solo before inviting their team.
For B2B products where the team context is required from day one (the product makes no sense for a single user), skip the personal org auto-creation and require org setup during onboarding. Use a multi-step onboarding wizard: step 1 (your profile) → step 2 (create or join org) → step 3 (invite teammates) → step 4 (product setup).
Methodology
Implementation patterns based on Makerkit and Supastarter source code (both use similar multi-tenant architectures). Time estimates based on community reports in the ShipFast Discord. Both Makerkit and Supastarter received significant multi-tenancy updates in 2025 — organization switching, invitation flows, and billing-per-org patterns are now more complete than in prior releases, making them the most reliable reference implementations for teams adding multi-tenancy to an existing boilerplate.
Find boilerplates with built-in multi-tenancy on StarterPick.
Check out this boilerplate
View ShipFaston StarterPick →