Demo

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.

database-per-tenant.txt
tenant-a.database.com  -> Tenant A's datatenant-b.database.com  -> Tenant B's datatenant-c.database.com  -> Tenant C's data

Pros:

  • 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).

schema-per-tenant.sql
-- PostgreSQL schemasCREATE SCHEMA tenant_a;CREATE SCHEMA tenant_b;CREATE SCHEMA tenant_c;-- Tables exist in each schematenant_a.userstenant_b.userstenant_c.users

Pros:

  • 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-database.sql
-- 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/schema.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:

src/db/schema.ts
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

subdomain-routing.txt
acme.yourapp.com    -> Tenant: acmeglobex.yourapp.com  -> Tenant: globex

This is the most professional approach, used by Slack, Linear, etc.

Next.js Middleware for Subdomain Resolution:

middleware.ts
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:

app/tenant/[slug]/page.tsx
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

path-routing.txt
yourapp.com/org/acme/dashboard   -> Tenant: acmeyourapp.com/org/globex/dashboard -> Tenant: globex

Simpler to implement, no DNS configuration needed.

app/org/[orgSlug]/dashboard/page.tsx
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:

app/api/projects/route.ts
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:

lib/tenant-context.ts
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:

app/org/[orgSlug]/layout.tsx
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:

lib/permissions.ts
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:

app/actions/projects.ts
'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:

components/org-switcher.tsx
'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:

lib/data.ts
// 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:

row-level-security.sql
-- 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:

lib/audit.ts
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:

  1. Isolation strategy: Shared database with tenant ID is usually the best balance of simplicity and isolation
  2. Tenant resolution: Subdomain-based for professional feel, path-based for simplicity
  3. Authorization: Always verify tenant membership and permissions on every request
  4. Data access: Never trust client input—always filter by verified tenant ID

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.