Skip to main content
Back to Blog
By Mahmut Jomaa3 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.

Isolated and shared tenant database arrangements beneath an application
On this page10 sections

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.