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!