Multi-Tenancy Architecture for SaaS: Complete Implementation Guide

Learn how to implement multi-tenancy in your SaaS application with organizations, teams, roles, and data isolation using Prisma and Next.js.

December 5, 2024
5 min read
By FastSaaS Team

Multi-Tenancy Architecture for SaaS

Multi-tenancy is the cornerstone of B2B SaaS applications. It allows multiple customers (tenants) to use your application while keeping their data completely isolated.

Multi-Tenancy Approaches

1. Database per Tenant

  • Pros: Complete isolation, easy compliance
  • Cons: Expensive, complex management

2. Schema per Tenant

  • Pros: Good isolation, moderate complexity
  • Cons: Schema migrations are complex

3. Row-Level Security (Recommended)

  • Pros: Cost-effective, simple, scalable
  • Cons: Requires careful query design

Database Schema

// prisma/schema.prisma

model Organization {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  logo        String?
  plan        Plan     @default(FREE)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  members     OrganizationMember[]
  invitations Invitation[]
  projects    Project[]
}

model OrganizationMember {
  id             String       @id @default(cuid())
  role           MemberRole   @default(MEMBER)
  joinedAt       DateTime     @default(now())

  user           User         @relation(fields: [userId], references: [id])
  userId         String

  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  organizationId String

  @@unique([userId, organizationId])
}

model Invitation {
  id             String       @id @default(cuid())
  email          String
  role           MemberRole   @default(MEMBER)
  token          String       @unique
  expiresAt      DateTime
  createdAt      DateTime     @default(now())

  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  organizationId String

  invitedBy      User         @relation(fields: [invitedById], references: [id])
  invitedById    String
}

enum MemberRole {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

enum Plan {
  FREE
  PRO
  ENTERPRISE
}

Organization Context

// lib/organization/context.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";

export async function getCurrentOrganization() {
  const session = await getServerSession();

  if (!session?.user?.id) {
    return null;
  }

  // Get user's active organization from session or default to first
  const membership = await prisma.organizationMember.findFirst({
    where: { userId: session.user.id },
    include: { organization: true },
    orderBy: { joinedAt: "asc" },
  });

  return membership?.organization || null;
}

export async function getUserOrganizations(userId: string) {
  const memberships = await prisma.organizationMember.findMany({
    where: { userId },
    include: { organization: true },
  });

  return memberships.map((m) => ({
    ...m.organization,
    role: m.role,
  }));
}

Role-Based Access Control

// lib/organization/permissions.ts
import { MemberRole } from "@prisma/client";

const permissions = {
  OWNER: [
    "org:delete",
    "org:update",
    "org:billing",
    "member:manage",
    "member:invite",
    "project:create",
    "project:delete",
    "project:update",
    "project:view",
  ],
  ADMIN: [
    "org:update",
    "member:manage",
    "member:invite",
    "project:create",
    "project:delete",
    "project:update",
    "project:view",
  ],
  MEMBER: ["member:view", "project:create", "project:update", "project:view"],
  VIEWER: ["member:view", "project:view"],
} as const;

export function hasPermission(role: MemberRole, permission: string): boolean {
  return permissions[role]?.includes(permission) || false;
}

export function requirePermission(role: MemberRole, permission: string): void {
  if (!hasPermission(role, permission)) {
    throw new Error(`Permission denied: ${permission}`);
  }
}

Team Invitations

// lib/organization/invitations.ts
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email/send-email";
import crypto from "crypto";

export async function inviteMember({
  organizationId,
  email,
  role,
  invitedById,
}: {
  organizationId: string;
  email: string;
  role: MemberRole;
  invitedById: string;
}) {
  const token = crypto.randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

  const invitation = await prisma.invitation.create({
    data: {
      email,
      role,
      token,
      expiresAt,
      organizationId,
      invitedById,
    },
    include: { organization: true },
  });

  // Send invitation email
  await sendEmail({
    to: email,
    subject: `You're invited to join ${invitation.organization.name}`,
    html: `
      <h1>Team Invitation</h1>
      <p>You've been invited to join ${invitation.organization.name}.</p>
      <a href="https://fastsaas.cloud/invite/${token}">Accept Invitation</a>
      <p>This invitation expires in 7 days.</p>
    `,
  });

  return invitation;
}

export async function acceptInvitation(token: string, userId: string) {
  const invitation = await prisma.invitation.findUnique({
    where: { token },
  });

  if (!invitation || invitation.expiresAt < new Date()) {
    throw new Error("Invalid or expired invitation");
  }

  // Add user to organization
  await prisma.organizationMember.create({
    data: {
      userId,
      organizationId: invitation.organizationId,
      role: invitation.role,
    },
  });

  // Delete the invitation
  await prisma.invitation.delete({
    where: { id: invitation.id },
  });

  return invitation.organizationId;
}

Organization Switcher Component

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

interface Organization {
  id: string;
  name: string;
  logo?: string;
  role: string;
}

export function OrganizationSwitcher({
  organizations,
  currentOrg,
}: {
  organizations: Organization[];
  currentOrg: Organization;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const router = useRouter();

  const switchOrg = async (orgId: string) => {
    await fetch("/api/user/switch-org", {
      method: "POST",
      body: JSON.stringify({ organizationId: orgId }),
    });

    router.refresh();
    setIsOpen(false);
  };

  return (
    <div className="relative">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100"
      >
        <span className="font-medium">{currentOrg.name}</span>
        <ChevronDown className="w-4 h-4" />
      </button>

      {isOpen && (
        <div className="absolute top-full left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border">
          {organizations.map((org) => (
            <button
              key={org.id}
              onClick={() => switchOrg(org.id)}
              className="w-full p-3 text-left hover:bg-gray-50 flex items-center gap-3"
            >
              <span>{org.name}</span>
              <span className="text-xs text-gray-500">{org.role}</span>
            </button>
          ))}

          <hr className="my-2" />

          <button className="w-full p-3 text-left hover:bg-gray-50 text-primary-600">
            + Create Organization
          </button>
        </div>
      )}
    </div>
  );
}

Data Isolation in Queries

// Always scope queries to the current organization

// ❌ BAD - No tenant isolation
const projects = await prisma.project.findMany();

// ✅ GOOD - Scoped to organization
const org = await getCurrentOrganization();
const projects = await prisma.project.findMany({
  where: { organizationId: org.id },
});

Conclusion

Multi-tenancy is essential for B2B SaaS. FastSaaS includes complete multi-tenancy with organizations, roles, invitations, and data isolation built-in.

Get FastSaaS with production-ready multi-tenancy!