Skip to main content

Single-Tenant to Multi-Tenant Boilerplate 2026

·StarterPick Team
Share:

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

PitfallConsequenceFix
Querying by userId instead of orgIdData leaks between orgsAlways query by organizationId
Forgetting to migrate billing dataUsers lose subscriptionInclude subscriptions in migration
No role checks on mutationsMembers can delete org dataCheck role on every write operation
Invitations expire but not cleanedDB bloatAdd expiry + cron cleanup
Stripe customer per user (not org)Billing breaks for teamsMigrate 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.


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 →

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.