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 data
tenant-b.database.com  -> Tenant B's data
tenant-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 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.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 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.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:

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 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

subdomain-routing.txt
acme.yourapp.com    -> Tenant: acme
globex.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: acme
yourapp.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 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:

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 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:

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

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.