Wednesday, March 5th 2025 ยท 3 min read
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.
Multi-tenancy is what transforms a single-user application into a scalable B2B SaaS. It allows multiple organizations (tenants) to use your application while keeping their data completely isolated.
In this step-by-step guide, we'll implement multi-tenancy in a Next.js application from scratch, covering database design, middleware, and security.
Understanding Multi-Tenancy Models
Before writing code, you need to choose a tenancy model:
1. Shared Database, Shared Schema
All tenants share tables with a tenant_id column:
-- Single users table for all tenants
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
name VARCHAR(255)
);
-- Every query includes tenant filter
SELECT * FROM users WHERE tenant_id = 'abc-123';Pros: Simple, cost-effective, easy migrations Cons: Query discipline required, potential for data leaks
2. Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema:
-- Tenant-specific schema
CREATE SCHEMA tenant_abc123;
CREATE TABLE tenant_abc123.users (...);
-- Different tenant
CREATE SCHEMA tenant_xyz789;
CREATE TABLE tenant_xyz789.users (...);Pros: Better isolation, same database instance Cons: Complex migrations, schema management overhead
3. Separate Databases
Each tenant gets a completely isolated database:
postgres://host/tenant_abc123
postgres://host/tenant_xyz789Pros: Maximum isolation, independent scaling Cons: Expensive, complex deployment
For most SaaS applications, shared database with shared schema provides the best balance of simplicity, cost, and security. That's what we'll implement.
Step 1: Database Schema Design
Let's design a multi-tenant schema using Prisma:
// prisma/schema.prisma
model Organization {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
memberships Membership[]
invitations Invitation[]
projects Project[]
@@map("organizations")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
memberships Membership[]
invitations Invitation[] @relation("InvitedBy")
@@map("users")
}
model Membership {
id String @id @default(cuid())
role Role @default(MEMBER)
createdAt DateTime @default(now())
// Relations
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])
@@map("memberships")
}
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
@@map("projects")
}
enum Role {
OWNER
ADMIN
MEMBER
}Key design decisions:
- Users exist independently of organizations (can belong to multiple)
- Membership is the join table with role information
- All tenant resources have an
organizationIdforeign key
Step 2: Organization Context Provider
Create a React context to track the current organization:
// contexts/organization-context.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
type Organization = {
id: string;
name: string;
slug: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
};
type OrganizationContextType = {
organization: Organization | null;
setOrganization: (org: Organization | null) => void;
isLoading: boolean;
};
const OrganizationContext = createContext<OrganizationContextType | undefined>(
undefined
);
export function OrganizationProvider({
children,
initialOrganization
}: {
children: React.ReactNode;
initialOrganization: Organization | null;
}) {
const [organization, setOrganization] = useState<Organization | null>(
initialOrganization
);
const [isLoading, setIsLoading] = useState(false);
return (
<OrganizationContext.Provider
value={{ organization, setOrganization, isLoading }}
>
{children}
</OrganizationContext.Provider>
);
}
export function useOrganization() {
const context = useContext(OrganizationContext);
if (context === undefined) {
throw new Error('useOrganization must be used within OrganizationProvider');
}
return context;
}Step 3: Tenant-Aware Data Access Layer
Create a data access layer that enforces tenant isolation:
// lib/dal/projects.ts
import { getCurrentOrganization } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getProjects() {
const org = await getCurrentOrganization();
if (!org) {
throw new Error('No organization selected');
}
return db.project.findMany({
where: {
organizationId: org.id // Always filter by tenant
},
orderBy: {
createdAt: 'desc'
}
});
}
export async function getProject(id: string) {
const org = await getCurrentOrganization();
if (!org) {
throw new Error('No organization selected');
}
const project = await db.project.findFirst({
where: {
id,
organizationId: org.id // Prevent accessing other tenants' data
}
});
if (!project) {
throw new Error('Project not found');
}
return project;
}
export async function createProject(data: {
name: string;
description?: string;
}) {
const org = await getCurrentOrganization();
if (!org) {
throw new Error('No organization selected');
}
return db.project.create({
data: {
...data,
organizationId: org.id // Always set tenant
}
});
}
export async function deleteProject(id: string) {
const org = await getCurrentOrganization();
if (!org) {
throw new Error('No organization selected');
}
// Verify ownership before deletion
const project = await db.project.findFirst({
where: {
id,
organizationId: org.id
}
});
if (!project) {
throw new Error('Project not found');
}
return db.project.delete({
where: { id }
});
}Step 4: Middleware for Tenant Resolution
Create middleware to resolve the tenant from the URL or session:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip middleware for public routes
if (
pathname.startsWith('/api/auth') ||
pathname.startsWith('/_next') ||
pathname === '/login' ||
pathname === '/signup'
) {
return NextResponse.next();
}
// Check authentication
const token = await getToken({ req: request });
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Check if accessing organization routes
if (pathname.startsWith('/dashboard/')) {
const orgSlug = pathname.split('/')[2];
if (orgSlug) {
// Verify user has access to this organization
// In production, you'd cache this check
const hasAccess = await verifyOrganizationAccess(
token.sub as string,
orgSlug
);
if (!hasAccess) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Add organization to headers for server components
const response = NextResponse.next();
response.headers.set('x-organization-slug', orgSlug);
return response;
}
}
return NextResponse.next();
}
async function verifyOrganizationAccess(
userId: string,
orgSlug: string
): Promise<boolean> {
// Implement your verification logic
// This should be cached/optimized in production
return true;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};Step 5: Organization Switching UI
Build a component for switching between organizations:
// components/organization-switcher.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrganization } from '@/contexts/organization-context';
import { Check, ChevronsUpDown, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
type Organization = {
id: string;
name: string;
slug: string;
role: string;
};
export function OrganizationSwitcher({
organizations
}: {
organizations: Organization[];
}) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { organization, setOrganization } = useOrganization();
const handleSelect = (org: Organization) => {
setOrganization(org);
setOpen(false);
router.push(`/dashboard/${org.slug}`);
};
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{organization?.name ?? 'Select organization...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search organization..." />
<CommandList>
<CommandEmpty>No organization found.</CommandEmpty>
<CommandGroup heading="Organizations">
{organizations.map((org) => (
<CommandItem
key={org.id}
onSelect={() => handleSelect(org)}
>
<Check
className={`mr-2 h-4 w-4 ${
organization?.id === org.id ? 'opacity-100' : 'opacity-0'
}`}
/>
{org.name}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false);
router.push('/dashboard/new');
}}
>
<PlusCircle className="mr-2 h-4 w-4" />
Create Organization
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}Step 6: Server Actions with Tenant Isolation
Create secure server actions that enforce tenant boundaries:
// actions/projects.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { getCurrentOrganization, getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';
const createProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional()
});
export async function createProjectAction(formData: FormData) {
const user = await getCurrentUser();
const org = await getCurrentOrganization();
if (!user || !org) {
return { error: 'Unauthorized' };
}
// Check user has permission to create projects
if (org.role === 'MEMBER') {
return { error: 'Insufficient permissions' };
}
const validated = createProjectSchema.safeParse({
name: formData.get('name'),
description: formData.get('description')
});
if (!validated.success) {
return { error: 'Invalid input' };
}
try {
const project = await db.project.create({
data: {
name: validated.data.name,
description: validated.data.description,
organizationId: org.id // Always set from session, never from client
}
});
revalidatePath(`/dashboard/${org.slug}/projects`);
return { success: true, project };
} catch (error) {
return { error: 'Failed to create project' };
}
}
export async function deleteProjectAction(projectId: string) {
const user = await getCurrentUser();
const org = await getCurrentOrganization();
if (!user || !org) {
return { error: 'Unauthorized' };
}
// Verify the project belongs to current organization
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: org.id // Critical: prevents cross-tenant access
}
});
if (!project) {
return { error: 'Project not found' };
}
// Check permissions
if (org.role !== 'OWNER' && org.role !== 'ADMIN') {
return { error: 'Insufficient permissions' };
}
await db.project.delete({
where: { id: projectId }
});
revalidatePath(`/dashboard/${org.slug}/projects`);
return { success: true };
}Step 7: Role-Based Access Control
Implement RBAC for fine-grained permissions:
// lib/permissions.ts
type Permission =
| 'project:create'
| 'project:read'
| 'project:update'
| 'project:delete'
| 'member:invite'
| 'member:remove'
| 'billing:manage'
| 'organization:settings';
const rolePermissions: Record<string, Permission[]> = {
OWNER: [
'project:create',
'project:read',
'project:update',
'project:delete',
'member:invite',
'member:remove',
'billing:manage',
'organization:settings'
],
ADMIN: [
'project:create',
'project:read',
'project:update',
'project:delete',
'member:invite',
'member:remove'
],
MEMBER: ['project:read', 'project:update']
};
export function hasPermission(role: string, permission: Permission): boolean {
return rolePermissions[role]?.includes(permission) ?? false;
}
export function requirePermission(role: string, permission: Permission) {
if (!hasPermission(role, permission)) {
throw new Error(`Missing permission: ${permission}`);
}
}Security Considerations
1. Never Trust Client Input
// BAD: organizationId from client
await db.project.create({
data: {
name: data.name,
organizationId: data.organizationId // Attacker can set any ID!
}
});
// GOOD: organizationId from server session
const org = await getCurrentOrganization();
await db.project.create({
data: {
name: data.name,
organizationId: org.id // Always from authenticated session
}
});2. Always Filter Queries
// BAD: No tenant filter
const project = await db.project.findUnique({
where: { id: projectId }
});
// GOOD: Always include tenant filter
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: org.id
}
});3. Use Database-Level Policies
For PostgreSQL, add row-level security as a safety net:
-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their organization's projects
CREATE POLICY tenant_isolation ON projects
FOR ALL
USING (organization_id = current_setting('app.current_organization_id')::uuid);Testing Multi-Tenancy
Write tests that verify tenant isolation:
// tests/multi-tenancy.test.ts
import { describe, expect, it } from 'vitest';
describe('Multi-tenancy isolation', () => {
it('should not allow cross-tenant project access', async () => {
// Create two organizations
const org1 = await createOrganization('Org 1');
const org2 = await createOrganization('Org 2');
// Create project in org1
const project = await createProject(org1.id, { name: 'Secret Project' });
// Try to access from org2 context
const result = await getProject(org2.id, project.id);
expect(result).toBeNull();
});
it('should prevent tenant ID manipulation', async () => {
const org1 = await createOrganization('Org 1');
const org2 = await createOrganization('Org 2');
// Try to create project in org2 while authenticated as org1
const result = await createProjectAction({
name: 'Malicious Project',
organizationId: org2.id // Should be ignored
});
// Project should be created in org1, not org2
const project = await db.project.findFirst({
where: { name: 'Malicious Project' }
});
expect(project?.organizationId).toBe(org1.id);
});
});Conclusion
Implementing multi-tenancy requires careful attention to data isolation at every layer:
- Database design with proper foreign keys and constraints
- Server-side context to track the current tenant
- Data access layer that enforces tenant filters
- Middleware to verify tenant access
- Server actions that never trust client input
- RBAC for fine-grained permissions
- Testing to verify isolation
The patterns in this guide scale from startups to enterpriseโthe key is building tenant isolation into your architecture from the start.
Want multi-tenancy without building it yourself? Achromatic includes production-ready organization management, member invites, role-based permissions, and tenant isolation out of the box.
Related Articles
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-Organization & Monorepo
Announcing the latest version of Achromatic with multi-organization support and monorepo integration, providing better project management and improved development workflows.
How to Implement Metered Billing with Stripe in Next.js
Learn how to implement usage-based metered billing with Stripe in your Next.js SaaS. Covers metered subscriptions, usage reporting, and real-time tracking.