Friday, February 28th 2025 ยท 3 min read
Multi-Tenant Architecture Patterns in Next.js
Learn how to build multi-tenant SaaS applications in Next.js. Explore different tenant isolation strategies, database schemas, subdomain routing, and middleware patterns.
Multi-tenancy is the backbone of modern SaaS applications. It allows you to serve multiple customers (tenants) from a single codebase while keeping their data isolated and secure.
In this guide, we'll explore the different multi-tenant architecture patterns you can implement in Next.js, with real code examples for each approach.
What is Multi-Tenancy?
Multi-tenancy means a single instance of your application serves multiple tenants (organizations, teams, or customers). Each tenant:
- Has isolated data that other tenants can't access
- May have custom settings, branding, or features
- Shares the same application infrastructure
Common examples: Slack (workspaces), Notion (workspaces), Linear (teams), Vercel (teams).
Tenant Isolation Strategies
There are three main approaches to tenant isolation:
1. Database-per-Tenant
Each tenant gets their own database. Maximum isolation but highest operational overhead.
tenant-a.database.com -> Tenant A's data
tenant-b.database.com -> Tenant B's data
tenant-c.database.com -> Tenant C's dataPros:
- Complete data isolation
- Easy to comply with data residency requirements
- Can scale databases independently
Cons:
- High operational overhead
- Expensive at scale
- Complex deployment and migrations
2. Schema-per-Tenant
Single database with separate schemas for each tenant (PostgreSQL supports this well).
-- PostgreSQL schemas
CREATE SCHEMA tenant_a;
CREATE SCHEMA tenant_b;
CREATE SCHEMA tenant_c;
-- Tables exist in each schema
tenant_a.users
tenant_b.users
tenant_c.usersPros:
- Good isolation without separate databases
- Easier to manage than database-per-tenant
- Can leverage PostgreSQL RLS (Row Level Security)
Cons:
- Schema migrations become complex
- Not all databases support this well
- Still some operational overhead
3. Shared Database with Tenant ID (Most Common)
All tenants share the same tables with a tenant_id or organization_id column.
-- Shared tables with tenant isolation
CREATE TABLE users (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
email TEXT NOT NULL,
name TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE projects (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Every query filters by organization_id
SELECT * FROM projects WHERE organization_id = $1;Pros:
- Simplest to implement and maintain
- Single migration path
- Most cost-effective
Cons:
- Risk of data leakage if queries forget the filter
- Harder to comply with strict data residency requirements
Database Schema Design
Here's a complete multi-tenant schema using Prisma:
// Prisma schema
model Organization {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Tenant data
members Member[]
projects Project[]
invitations Invitation[]
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// User can belong to multiple organizations
memberships Member[]
}
model Member {
id String @id @default(cuid())
role Role @default(MEMBER)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
@@unique([userId, organizationId])
}
model Project {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Tenant isolation
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
tasks Task[]
}
model Task {
id String @id @default(cuid())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
}
enum Role {
OWNER
ADMIN
MEMBER
}
enum Plan {
FREE
PRO
ENTERPRISE
}And the equivalent in Drizzle:
import { relations } from 'drizzle-orm';
import { boolean, pgEnum, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['OWNER', 'ADMIN', 'MEMBER']);
export const planEnum = pgEnum('plan', ['FREE', 'PRO', 'ENTERPRISE']);
export const organizations = pgTable('organizations', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
plan: planEnum('plan').default('FREE').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const users = pgTable('users', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull().unique(),
name: text('name'),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const members = pgTable('members', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
role: roleEnum('role').default('MEMBER').notNull(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const projects = pgTable('projects', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
description: text('description'),
organizationId: text('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const tasks = pgTable('tasks', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
completed: boolean('completed').default(false).notNull(),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull()
});
// Define relations
export const organizationsRelations = relations(organizations, ({ many }) => ({
members: many(members),
projects: many(projects)
}));
export const usersRelations = relations(users, ({ many }) => ({
memberships: many(members)
}));
export const membersRelations = relations(members, ({ one }) => ({
user: one(users, { fields: [members.userId], references: [users.id] }),
organization: one(organizations, {
fields: [members.organizationId],
references: [organizations.id]
})
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
organization: one(organizations, {
fields: [projects.organizationId],
references: [organizations.id]
}),
tasks: many(tasks)
}));Tenant Resolution Strategies
How do you identify which tenant a request belongs to? Here are the main approaches:
1. Subdomain-Based Routing
acme.yourapp.com -> Tenant: acme
globex.yourapp.com -> Tenant: globexThis is the most professional approach, used by Slack, Linear, etc.
Next.js Middleware for Subdomain Resolution:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const url = request.nextUrl.clone();
// Get subdomain
// e.g., "acme.yourapp.com" -> "acme"
// e.g., "acme.localhost:3000" -> "acme"
const subdomain = hostname.split('.')[0];
// Skip for main domain and special subdomains
const isMainDomain =
hostname === 'yourapp.com' || hostname === 'www.yourapp.com';
const isLocalhost = hostname.includes('localhost');
const isSpecialSubdomain = ['www', 'app', 'api'].includes(subdomain);
if (isMainDomain || isSpecialSubdomain) {
return NextResponse.next();
}
// For local development, handle "acme.localhost:3000"
if (isLocalhost && subdomain !== 'localhost') {
// Rewrite to tenant-specific route
url.pathname = `/tenant/${subdomain}${url.pathname}`;
return NextResponse.rewrite(url);
}
// For production subdomains
if (!isMainDomain && !isLocalhost) {
url.pathname = `/tenant/${subdomain}${url.pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
};Dynamic Route Handler:
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
interface TenantPageProps {
params: Promise<{ slug: string }>;
}
export default async function TenantPage({ params }: TenantPageProps) {
const { slug } = await params;
const organization = await prisma.organization.findUnique({
where: { slug },
include: {
projects: true,
},
});
if (!organization) {
notFound();
}
return (
<div>
<h1>Welcome to {organization.name}</h1>
<h2>Projects</h2>
<ul>
{organization.projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
</div>
);
}2. Path-Based Routing
yourapp.com/org/acme/dashboard -> Tenant: acme
yourapp.com/org/globex/dashboard -> Tenant: globexSimpler to implement, no DNS configuration needed.
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
interface DashboardPageProps {
params: Promise<{ orgSlug: string }>;
}
export default async function DashboardPage({ params }: DashboardPageProps) {
const { orgSlug } = await params;
const organization = await prisma.organization.findUnique({
where: { slug: orgSlug },
});
if (!organization) {
notFound();
}
// Fetch tenant-specific data
const projects = await prisma.project.findMany({
where: { organizationId: organization.id },
});
return (
<div>
<h1>{organization.name} Dashboard</h1>
{/* Dashboard content */}
</div>
);
}3. Header-Based (API Tokens)
For APIs, use headers to identify tenants:
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
// Get tenant from API key or header
const apiKey = request.headers.get('X-API-Key');
if (!apiKey) {
return NextResponse.json({ error: 'API key required' }, { status: 401 });
}
// Lookup organization by API key
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
include: { organization: true }
});
if (!apiKeyRecord) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
}
// Fetch tenant-scoped data
const projects = await prisma.project.findMany({
where: { organizationId: apiKeyRecord.organizationId }
});
return NextResponse.json({ projects });
}Tenant Context Pattern
Create a context to access tenant information throughout your app:
import { cache } from 'react';
import { prisma } from '@/lib/prisma';
export type Tenant = {
id: string;
name: string;
slug: string;
plan: 'FREE' | 'PRO' | 'ENTERPRISE';
};
// Server-side tenant context using React cache
export const getTenant = cache(async (slug: string): Promise<Tenant | null> => {
const organization = await prisma.organization.findUnique({
where: { slug },
select: {
id: true,
name: true,
slug: true,
plan: true
}
});
return organization;
});
// Type-safe tenant-scoped queries
export const getTenantProjects = cache(async (tenantId: string) => {
return prisma.project.findMany({
where: { organizationId: tenantId },
orderBy: { createdAt: 'desc' }
});
});
export const getTenantMembers = cache(async (tenantId: string) => {
return prisma.member.findMany({
where: { organizationId: tenantId },
include: { user: true },
orderBy: { createdAt: 'asc' }
});
});Using the Tenant Context in Pages:
import { notFound, redirect } from 'next/navigation';
import { getTenant } from '@/lib/tenant-context';
import { auth } from '@/lib/auth';
interface TenantLayoutProps {
children: React.ReactNode;
params: Promise<{ orgSlug: string }>;
}
export default async function TenantLayout({
children,
params
}: TenantLayoutProps) {
const { orgSlug } = await params;
const session = await auth();
if (!session?.user) {
redirect('/login');
}
const tenant = await getTenant(orgSlug);
if (!tenant) {
notFound();
}
// Verify user has access to this tenant
const membership = await prisma.member.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId: tenant.id,
},
},
});
if (!membership) {
redirect('/unauthorized');
}
return (
<div>
<TenantHeader tenant={tenant} membership={membership} />
<main>{children}</main>
</div>
);
}Authorization & Access Control
Implement role-based access control within each tenant:
type Role = 'OWNER' | 'ADMIN' | 'MEMBER';
type Permission =
| 'project:create'
| 'project:read'
| 'project:update'
| 'project:delete'
| 'member:invite'
| 'member:remove'
| 'settings:update'
| 'billing:manage';
const rolePermissions: Record<Role, Permission[]> = {
OWNER: [
'project:create',
'project:read',
'project:update',
'project:delete',
'member:invite',
'member:remove',
'settings:update',
'billing:manage'
],
ADMIN: [
'project:create',
'project:read',
'project:update',
'project:delete',
'member:invite',
'member:remove',
'settings:update'
],
MEMBER: ['project:create', 'project:read', 'project:update']
};
export function hasPermission(role: Role, permission: Permission): boolean {
return rolePermissions[role].includes(permission);
}
export function requirePermission(role: Role, permission: Permission): void {
if (!hasPermission(role, permission)) {
throw new Error(`Permission denied: ${permission}`);
}
}Using Permissions in Server Actions:
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { requirePermission } from '@/lib/permissions';
import { prisma } from '@/lib/prisma';
export async function createProject(
organizationId: string,
data: { name: string; description?: string }
) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Get user's role in this organization
const membership = await prisma.member.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId
}
}
});
if (!membership) {
throw new Error('Not a member of this organization');
}
// Check permission
requirePermission(membership.role, 'project:create');
// Create the project (automatically scoped to tenant)
const project = await prisma.project.create({
data: {
name: data.name,
description: data.description,
organizationId // Tenant isolation
}
});
revalidatePath(`/org/${organizationId}/projects`);
return project;
}
export async function deleteProject(projectId: string) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Get the project to find its organization
const project = await prisma.project.findUnique({
where: { id: projectId }
});
if (!project) {
throw new Error('Project not found');
}
// Get user's role in this organization
const membership = await prisma.member.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId: project.organizationId
}
}
});
if (!membership) {
throw new Error('Not a member of this organization');
}
// Check permission
requirePermission(membership.role, 'project:delete');
await prisma.project.delete({
where: { id: projectId }
});
revalidatePath(`/org/${project.organizationId}/projects`);
}Organization Switching
Allow users to switch between organizations they belong to:
'use client';
import { useRouter } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Organization {
id: string;
name: string;
slug: string;
}
interface OrgSwitcherProps {
organizations: Organization[];
currentOrgSlug: string;
}
export function OrgSwitcher({ organizations, currentOrgSlug }: OrgSwitcherProps) {
const router = useRouter();
const handleOrgChange = (slug: string) => {
router.push(`/org/${slug}/dashboard`);
};
return (
<Select value={currentOrgSlug} onValueChange={handleOrgChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.slug}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}Best Practices
1. Always Filter by Tenant ID
Never trust client-side tenant information. Always verify and filter on the server:
// BAD - Trust client input
const projects = await prisma.project.findMany({
where: { organizationId: request.body.organizationId } // Dangerous!
});
// GOOD - Verify membership first
const membership = await prisma.member.findUnique({
where: {
userId_organizationId: {
userId: session.user.id,
organizationId: request.body.organizationId
}
}
});
if (!membership) {
throw new Error('Unauthorized');
}
const projects = await prisma.project.findMany({
where: { organizationId: membership.organizationId }
});2. Use Database-Level Security (Optional)
PostgreSQL Row Level Security adds an extra layer of protection:
-- Enable RLS on projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create policy for tenant isolation
CREATE POLICY tenant_isolation_policy ON projects
USING (organization_id = current_setting('app.current_tenant_id')::uuid);3. Audit Logging
Track who did what in each tenant:
export async function logAuditEvent({
organizationId,
userId,
action,
resourceType,
resourceId,
metadata
}: {
organizationId: string;
userId: string;
action: string;
resourceType: string;
resourceId: string;
metadata?: Record<string, unknown>;
}) {
await prisma.auditLog.create({
data: {
organizationId,
userId,
action,
resourceType,
resourceId,
metadata: metadata ? JSON.stringify(metadata) : null
}
});
}Conclusion
Multi-tenant architecture is essential for building scalable SaaS applications. The key decisions are:
- Isolation strategy: Shared database with tenant ID is usually the best balance of simplicity and isolation
- Tenant resolution: Subdomain-based for professional feel, path-based for simplicity
- Authorization: Always verify tenant membership and permissions on every request
- Data access: Never trust client inputโalways filter by verified tenant ID
Related Articles
- Prisma vs Drizzle ORM - Choose the right ORM for your multi-tenant database
- Implementing Stripe Billing in Next.js - Add per-organization billing to your multi-tenant app
- Building a SaaS Dashboard with React Server Components - Build tenant-aware dashboards
Ready to build your multi-tenant SaaS? Our starter kits come with multi-tenancy built in:
- Prisma Kit - Organizations, roles, and invitations pre-configured
- Drizzle Kit - Lightweight multi-tenancy with type-safe queries
Check out our pricing to get started building your multi-tenant SaaS today.
Related Articles
How to Implement Multi-Tenancy in Next.js: A Step-by-Step Guide
A practical guide to implementing multi-tenant architecture in Next.js. Learn database strategies, middleware patterns, and security considerations with working code examples.
CVE-2026-23864 - React Server Components DoS Vulnerabilities
Multiple denial of service vulnerabilities discovered in React Server Components. All Achromatic starter kits updated to patched versions.
Introducing shadcn-modal-manager
We open sourced shadcn-modal-manager - a lightweight, type-safe modal manager for shadcn/ui built with pure React and zero dependencies.