Skip to main content

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

StepDuration
Database schema1 day
API context middleware1 day
Permission system1 day
Invitation flow2 days
UI (org switcher, members page)2 days
Testing and edge cases1 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 →

Comments