Skip to main content

Add Multi-Tenancy to Any SaaS Boilerplate 2026

·StarterPick Team
Share:

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:

  1. Create a Stripe Customer when an org is created
  2. Attach the Stripe Customer ID to the Organization record
  3. Stripe subscriptions belong to the org, not the individual user
  4. 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

StepDuration
Database schema + migration1 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.


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.


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 →

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.