Chapter 9: [Deep Dive] Multi-Tenant Architecture Design
Welcome to Chapter Nine. So far, we've built a single-user system: users log in and create their own projects. But almost all successful SAAS (Software as a Service) applications (like Slack, Notion, Figma) are not single-user systems, they are multi-tenant systems.
Chapter 9: [Deep Dive] Multi-Tenant Architecture Design
Welcome to Chapter Nine. So far, we've built a single-user system: users log in and create their own projects. But almost all successful SAAS (Software as a Service) applications (like Slack, Notion, Figma) are not single-user systems, they are multi-tenant systems.
A "tenant" is typically an Organization, Workspace, or Team. Users are members of the tenant.
A user can create or join multiple tenants (for example, you might be in both company A's and personal project B's Slack workspaces). A SAAS application must architecturally guarantee that tenant A's data (projects, documents, messages) cannot absolutely be seen by tenant B's members.
Implementing this data isolation is the core of multi-tenant architecture design.
9.1. Selection: Separate Schema vs. Shared Database (tenant_id)
You have two mainstream approaches to implement tenant isolation, similar to two models of "renting" in the real world:
Approach A: Separate Database / Schema (Isolated Tenancy)
- What is it? For each newly registered tenant (Organization), create a completely new, independent database or Schema (in Postgres, Schema is lighter).
- Analogy: You give each tenant a separate villa.
- Advantages:
- Extremely high data isolation: Data is physically separated. A bug in tenant A's code can absolutely never accidentally query tenant B's data.
- No "noisy neighbors": Tenant A's intensive queries won't affect tenant B's performance.
- Easy to backup/migrate: You can easily backup or migrate data for a specific tenant.
- Disadvantages:
- High cost: 1000 tenants = 1000 Schemas.
- Maintenance nightmare: When you need to modify a table structure (database migration), you must successfully run it on all 1000 Schemas.
- Complex connections: Applications need to dynamically switch database connections or Schemas.
Approach B: Shared Database, tenant_id Filtering (Shared Tenancy)
- What is it? All tenants share the same database and the same set of tables. On every table that needs isolation (like
projects,documents), add anorganization_id(orworkspace_id) field. - Analogy: You assign each tenant an apartment in the same apartment building.
- Advantages:
- Low cost, easy to maintain: Only one database. Database migrations only need to run once.
- Easy to scale: Adding new tenants is just adding rows to tables, almost zero cost.
- Easy to aggregate data: You can easily analyze across all tenants (e.g., "how many total projects do we have?").
- Disadvantages:
- Isolation depends on application layer: Security completely depends on your code. If you forget to add
WHERE organization_id = ?in a query, you'll immediately cause catastrophic data leakage. - "Noisy neighbors": A very large query from tenant A could slow down the entire database, affecting tenant B.
- Isolation depends on application layer: Security completely depends on your code. If you forget to add
Conclusion:
For the vast majority of SAAS applications, especially our template project, Approach B (shared database + tenant_id****) is the modern standard choice. It provides the best balance of cost, performance, and maintainability.
Our challenge going forward is how to 100% strictly enforce tenant_id filtering to prevent data leakage.
9.2. Implementation: Resolving Tenant Context in Middleware
Before we start querying, the application needs to know two things:
- "Who are you?" (Authentication) ->
userId - "Which tenant are you currently representing?" (Tenancy) ->
organizationId
We solved problem 1 in Chapter 8 through better-auth. The auth() function can tell us session.user.id.
For problem 2, a user might belong to multiple organizations, how do we know which one they're currently operating in?
- Approach 1: Subdomain (e.g.,
tenant-a.saas.com). Middleware parses fromhostheader. - Approach 2: URL Path (e.g.,
saas.com/org/tenant-a/dashboard). Middleware parses frompathname. - Approach 3: Client-side Cookie / Session. After user logs in, they select an organization, and we write
current_org_idto a cookie (like preferences in section 8.3) orsession.
In our实战项目, Approach 3 (Cookie) combined with Approach 2 (URL) is the most flexible. But whichever approach, Next.js Middleware (src/middleware.ts) is the best place to resolve this context.
// src/middleware.ts (concept)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'; // Import our auth configuration
export async function middleware(request: NextRequest) {
const session = await auth(); // 1. Get session
const pathname = request.nextUrl.pathname;
// 2. If not logged in and accessing protected pages, redirect to login
if (!session && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (session && pathname.startsWith('/dashboard')) {
// 3. [Tenant context]
// Here, we should get user's current 'activeOrgId' from cookie or session
// Or parse from pathname (e.g., /dashboard/[orgId]/...)
const activeOrgId = request.cookies.get('active_org_id')?.value;
if (!activeOrgId && pathname !== '/dashboard/select-org') {
// 4. If user is logged in but hasn't selected an organization, force them to select
return NextResponse.redirect(new URL('/dashboard/select-org', request.url));
}
// 5. (Advanced) Inject orgId into request headers for Server Components to access
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-org-id', activeOrgId);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
return NextResponse.next();
}
// ... config matcher ...Middleware ensures that any request entering /dashboard must have an associated userId (from session) and orgId (from cookie or URL).
9.3. [Code Analysis]: Analyzing tenant_id Pattern and Service Layer Isolation
Now that we have userId and orgId, how do we safely use them in every database query?
Step 1: Update Schema (****tenant_id pattern)
We must add organizationId to all resources that need isolation.
// src/db/schema/organizations.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
// 1. Tenant (Organization) table
export const organizationsTable = pgTable("organizations", {
id: varchar("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
});
// 2. Many-to-Many join table
// One user can belong to multiple organizations, one organization can have multiple users
export const usersToOrganizationsTable = pgTable("users_to_organizations", {
userId: varchar("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
organizationId: varchar("organization_id").notNull().references(() => organizationsTable.id, { onDelete: "cascade" }),
// (Can add a role field: 'admin', 'member')
});
// src/db/schema/projects.ts (modified)
import { organizationsTable } from "./organizations";
export const projectsTable = pgTable("projects", {
id: varchar("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
// [Key] No longer ownerId, but organizationId
// ownerId only represents creator, but ownership belongs to organization
organizationId: varchar("organization_id").notNull()
.references(() => organizationsTable.id, { onDelete: "cascade" }),
// (Optional) Still keep 'created_by_user_id'
// ownerId: varchar("owner_id")...
});Step 2: Implement Service Layer Isolation
This is the core strategy for preventing data leakage.
We never write db.select()... directly in Server Actions or API routes. Why? Because developers will forget to add where(eq(projectsTable.organizationId, orgId)).
Instead, we create a "service layer" (or "Repository"), the only place allowed to talk directly to db.
// src/data/projects.service.ts
// (Note: This is a .ts file, not a React component)
import { db } from "@/db";
import { projectsTable } from "@/db/schema";
import { and, eq } from "drizzle-orm";
// This is a security context passed from Server Action
// Guarantees the caller must be authenticated and have tenant context
type TenantContext = {
userId: string;
orgId: string;
};
/**
* [Secure] Get single project by ID, tenant isolation enforced
*/
export async function getProjectById(ctx: TenantContext, projectId: string) {
// Service layer's core responsibility: inject tenant ID
const project = await db.query.projectsTable.findFirst({
where: and(
eq(projectsTable.id, projectId),
eq(projectsTable.organizationId, ctx.orgId) // <--- [Security line]
)
});
if (!project) {
// Project not found, or project doesn't belong to tenant, uniformly return null or throw error
// Never leak information like "project exists but you don't have access"
return null;
}
return project;
}
/**
* [Secure] Get all projects for current tenant
*/
export async function getProjectsForCurrentOrg(ctx: TenantContext) {
const projects = await db.select()
.from(projectsTable)
.where(
eq(projectsTable.organizationId, ctx.orgId) // <--- [Security line]
);
return projects;
}Step 3: Use Service Layer in Server Actions
Now, our Server Action (see Chapter 6) becomes very clean and secure. It only handles authentication and context, not touching raw database queries.
// src/actions/project.actions.ts
"use server";
import { auth } from "@/lib/auth";
import { getActiveOrgIdForUser } from "@/data/organizations.service"; // Hypothetical function
import { getProjectById } from "@/data/projects.service"; // Import our secure service
import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";
// ...
const action = createSafeActionClient({
// 1. Middleware: Automatically get and validate Auth and tenant context
async middleware() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// Get current active orgId from cookie or database
const orgId = await getActiveOrgIdForUser(session.user.id);
if (!orgId) throw new Error("No active organization found");
// 2. Pass security context (ctx) to Action
return { userId: session.user.id, orgId };
},
});
const getProjectSchema = z.object({
projectId: z.string(),
});
/**
* [Secure] A safe action for client-side
*/
export const getProject = action(
getProjectSchema,
async ({ projectId }, ctx) => { // <-- ctx from middleware
// 3. Call service layer, pass security context
// We're 100% sure this call is tenant-isolated
const project = await getProjectById(ctx, projectId);
if (!project) {
return { error: "Project not found" };
}
return { success: true, data: project };
}
);Chapter Summary:
We laid the SAAS foundation through the shared database (tenant_id) pattern, then built a maintainable, testable, and highly secure data access layer through the service layer pattern.
This Middleware (context) -> Server Action (authentication) -> Service Layer (secure queries) architecture is the best practice for preventing data leakage in real-world SAAS projects.
Categories
tenant_id Filtering (Shared Tenancy)9.2. Implementation: Resolving Tenant Context in Middleware9.3. [Code Analysis]: Analyzing tenant_id Pattern and Service Layer IsolationMore Posts
Chapter 8: SAAS Authentication: Auth Solution Comparison
In Chapter 7, we chose the ORM path, deciding to use Drizzle to control our database. This decision directly impacts our Authentication solution.
Chapter 14: CI/CD (GitHub Actions & Vercel)
Welcome to Part Six: DevOps and Quality Assurance. In previous chapters, we've built a fully functional SAAS application covering all core features from database (Drizzle), authentication (better-auth), payments (Stripe) to operations (React Email). Now, it's time to ensure we can deliver these features to our users safely, reliably, and quickly...
Chapter 11: Payments and Subscriptions (Stripe)
Welcome to Part Five, the core business logic of our SAAS application. In previous chapters, we built a solid application foundation—from frontend UI, RSC data flow, secure Server Actions to type-safe Drizzle ORM and 'better-auth' authentication. Now, it's time to transform our application from a 'project' into a 'product': implementing paid subscriptions.