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 datatenant-b.database.com -> Tenant B's datatenant-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 schemasCREATE SCHEMA tenant_a;CREATE SCHEMA tenant_b;CREATE SCHEMA tenant_c;-- Tables exist in each schematenant_a.userstenant_b.userstenant_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 isolationCREATE 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_idSELECT * 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 schemamodel 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 relationsexport 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: acmeglobex.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: acmeyourapp.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 cacheexport 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 queriesexport 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 inputconst projects = await prisma.project.findMany({ where: { organizationId: request.body.organizationId } // Dangerous!});// GOOD - Verify membership firstconst 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 tableALTER TABLE projects ENABLE ROW LEVEL SECURITY;-- Create policy for tenant isolationCREATE 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.