Building Multi-Tenant SaaS Applications with Next.js: Architecture and Implementation
Design and build multi-tenant SaaS platforms with Next.js using subdomain routing, tenant isolation, RBAC, and scalable database patterns.
Building Multi-Tenant SaaS Applications with Next.js: Architecture and Implementation
Multi-tenant architecture is the backbone of modern SaaS platforms — serving multiple customers from a single codebase while keeping their data isolated and secure. Having built multi-tenant platforms like CertsLibrary (a SaaS platform with RBAC and OpenAI integration), I've learned that getting the tenant isolation layer right is critical for both security and scalability. This guide covers the architecture patterns that make multi-tenancy work in Next.js.
Multi-Tenancy Models
Shared Database, Shared Schema
All tenants share the same database and tables, differentiated by a tenantId column:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String
name String
tenantId String
role Role @default(MEMBER)
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
@@unique([email, tenantId])
@@index([tenantId])
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
customDomain String? @unique
users User[]
createdAt DateTime @default(now())
}
Pros: Simple deployment, efficient resource usage, easy migrations Cons: Requires diligent query filtering, shared performance ceiling
This is the right choice for 90% of SaaS startups. Only move to isolated databases when you have enterprise customers with compliance requirements.
Tenant Resolution with Middleware
Next.js middleware resolves the tenant for every request:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const url = request.nextUrl.clone();
// Extract tenant from subdomain
// e.g., acme.yourapp.com → "acme"
const currentHost = hostname.split('.')[0];
// Skip for main domain and API routes
if (
currentHost === 'www' ||
currentHost === 'yourapp' ||
url.pathname.startsWith('/api/')
) {
return NextResponse.next();
}
// Rewrite to tenant-specific route
url.pathname = `/tenant/${currentHost}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ['/((?!_next|favicon.ico|api).*)'],
};
Dynamic Route Structure
src/app/
├── tenant/
│ └── [slug]/
│ ├── layout.tsx # Tenant-scoped layout with branding
│ ├── page.tsx # Tenant dashboard
│ ├── settings/
│ │ └── page.tsx
│ └── members/
│ └── page.tsx
├── api/
│ └── tenants/
│ └── route.ts
└── (marketing)/
├── page.tsx # Landing page
└── pricing/
└── page.tsx
Role-Based Access Control (RBAC)
Permission System
Define granular permissions that compose into roles:
// lib/permissions.ts
export const PERMISSIONS = {
// Content permissions
'content:read': 'View content',
'content:create': 'Create content',
'content:update': 'Edit content',
'content:delete': 'Delete content',
// Member permissions
'members:read': 'View members',
'members:invite': 'Invite members',
'members:remove': 'Remove members',
'members:update-role': 'Change member roles',
// Settings permissions
'settings:read': 'View settings',
'settings:update': 'Modify settings',
'billing:manage': 'Manage billing',
} as const;
export type Permission = keyof typeof PERMISSIONS;
export const ROLES = {
OWNER: Object.keys(PERMISSIONS) as Permission[],
ADMIN: [
'content:read', 'content:create', 'content:update', 'content:delete',
'members:read', 'members:invite',
'settings:read', 'settings:update',
],
EDITOR: [
'content:read', 'content:create', 'content:update',
'members:read',
],
MEMBER: [
'content:read',
'members:read',
],
} as const;
export function hasPermission(role: keyof typeof ROLES, permission: Permission): boolean {
return (ROLES[role] as readonly Permission[]).includes(permission);
}
Server-Side Authorization
// lib/auth.ts
import { getServerSession } from 'next-auth';
import { hasPermission, Permission } from './permissions';
export async function requirePermission(
tenantSlug: string,
permission: Permission
) {
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
tenant: { slug: tenantSlug },
},
});
if (!membership || !hasPermission(membership.role, permission)) {
throw new Error('Forbidden');
}
return { user: session.user, membership };
}
// Usage in Server Component
// app/tenant/[slug]/settings/page.tsx
export default async function SettingsPage({
params,
}: {
params: { slug: string };
}) {
const { membership } = await requirePermission(params.slug, 'settings:read');
const settings = await prisma.tenantSettings.findUnique({
where: { tenantId: membership.tenantId },
});
return <SettingsForm settings={settings} canEdit={hasPermission(membership.role, 'settings:update')} />;
}
Tenant-Scoped Data Access
Repository Pattern with Tenant Context
Never trust the client to provide the tenant ID. Always derive it server-side:
// lib/tenant-context.ts
import { cache } from 'react';
import { headers } from 'next/headers';
export const getTenantContext = cache(async () => {
const headerList = await headers();
const host = headerList.get('host') || '';
const slug = host.split('.')[0];
const tenant = await prisma.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, name: true, plan: true },
});
if (!tenant) throw new Error('Tenant not found');
return tenant;
});
// lib/repositories/content.ts
export async function getContents(options?: { limit?: number }) {
const tenant = await getTenantContext();
return prisma.content.findMany({
where: { tenantId: tenant.id },
take: options?.limit,
orderBy: { createdAt: 'desc' },
});
}
Row-Level Security with Prisma Middleware
Add a safety net that prevents cross-tenant data access:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient().$extends({
query: {
$allModels: {
async findMany({ args, query }) {
// Ensure tenantId filter is always present on tenant-scoped models
if ('tenantId' in (args.where || {})) {
return query(args);
}
console.warn('Query without tenantId filter detected');
return query(args);
},
},
},
});
export default prisma;
Tenant Branding and Customization
Allow tenants to customize their workspace appearance:
// app/tenant/[slug]/layout.tsx
import { getTenantBranding } from '@/lib/repositories/branding';
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug: string };
}) {
const branding = await getTenantBranding(params.slug);
return (
<div
style={{
'--primary': branding.primaryColor,
'--accent': branding.accentColor,
} as React.CSSProperties}
>
<header className="bg-[var(--primary)]">
{branding.logo && (
<img src={branding.logo} alt={branding.companyName} className="h-8" />
)}
<h1>{branding.companyName}</h1>
</header>
<main>{children}</main>
</div>
);
}
Subscription and Plan Management
Feature Gating
// lib/plans.ts
export const PLAN_LIMITS = {
FREE: {
maxUsers: 3,
maxContent: 50,
features: ['content:basic'],
},
PRO: {
maxUsers: 20,
maxContent: 500,
features: ['content:basic', 'content:advanced', 'analytics', 'custom-domain'],
},
ENTERPRISE: {
maxUsers: Infinity,
maxContent: Infinity,
features: ['content:basic', 'content:advanced', 'analytics', 'custom-domain', 'sso', 'audit-log', 'api-access'],
},
} as const;
export function canUseFeature(plan: keyof typeof PLAN_LIMITS, feature: string): boolean {
return PLAN_LIMITS[plan].features.includes(feature);
}
// Usage in component
export async function AnalyticsPage({ params }: { params: { slug: string } }) {
const tenant = await getTenantContext();
if (!canUseFeature(tenant.plan, 'analytics')) {
return <UpgradePrompt feature="Analytics" requiredPlan="PRO" />;
}
return <AnalyticsDashboard tenantId={tenant.id} />;
}
API Route Protection
// app/api/tenants/[slug]/content/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth';
export async function POST(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const { membership } = await requirePermission(params.slug, 'content:create');
const body = await request.json();
const content = await prisma.content.create({
data: {
title: body.title,
body: body.body,
tenantId: membership.tenantId,
authorId: membership.userId,
},
});
return NextResponse.json(content, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (error instanceof Error && error.message === 'Forbidden') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
Key Takeaways
- Start with shared schema — It's simpler and sufficient for most SaaS products
- Resolve tenant in middleware — Never trust client-provided tenant IDs
- Compose permissions into roles — Granular permissions with role-based grouping
- Cache tenant context — Use React's
cache()to avoid redundant database queries - Feature gate by plan — Clean separation between plan limits and business logic
- Add safety nets — Prisma middleware catches missing tenant filters
Building multi-tenant SaaS is fundamentally about trust boundaries. Every data access must be scoped to a tenant, every action must be authorized, and every feature must respect plan limits. Get these foundations right, and your SaaS platform scales with confidence.