Skip to main content

How to Convert a Single-Tenant Boilerplate to Multi-Tenant (2026)

·StarterPick Team
multi-tenancyarchitecturenextjsguide2026

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.


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();

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>
  );
}

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

Find multi-tenant boilerplates that skip this migration on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments