How to Add Multi-Tenancy to Any SaaS Boilerplate (2026)
·StarterPick Team
multi-tenancyorganizationsguideboilerplate2026
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.
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[]
}
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
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);
}
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,
invitedByUserId: 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>
);
}
Time Budget
| Step | Duration |
|---|---|
| Database schema | 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.
Find boilerplates with built-in multi-tenancy on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →