February 19, 2026 · 13 min
Designing Multi-Tenant SaaS Architecture for Learning Management Systems
How I designed and built a multi-tenant SaaS LMS — covering tenant isolation strategies, authentication flows, role-based access, and the architectural decisions that shaped Smart LMS.
Designing Multi-Tenant SaaS Architecture for Learning Management Systems
TL;DR: Building Smart LMS taught me that multi-tenancy is not a feature you bolt on — it's a foundational architectural decision that touches every layer of the stack. Here's the complete blueprint.
What Is Multi-Tenancy and Why Does It Matter?
In a multi-tenant SaaS application, a single deployed instance serves multiple independent customers (tenants) — each with their own data, users, and settings — while sharing the same infrastructure.
The alternative — deploying a separate instance per customer — is simple but doesn't scale economically.
For Smart LMS, the requirements were:
- Multiple organizations ("tenants") can sign up independently
- Each tenant has their own courses, students, instructors, and settings
- Data must be completely isolated between tenants
- All tenants share the same codebase and database infrastructure
Tenant Isolation: Choosing the Right Strategy
There are three main approaches to multi-tenant data isolation:
Option 1: Database-per-Tenant
Each tenant gets their own PostgreSQL database.
Pros: Maximum isolation, easy data deletion on churn Cons: Expensive, hard to manage migrations across hundreds of databases
Option 2: Schema-per-Tenant
Each tenant gets their own PostgreSQL schema within a shared database.
Pros: Good isolation, single database to manage Cons: Complex migrations, PostgreSQL schema limits, harder to query across tenants
Option 3: Row-Level Multitenancy (Shared Schema)
All tenants share the same tables, with a tenantId column on every row.
Pros: Simple to operate, easy cross-tenant analytics, minimal infrastructure overhead Cons: Requires disciplined query filtering everywhere
I chose Option 3 for Smart LMS, combined with PostgreSQL Row-Level Security (RLS) as a safety net.
Database Schema Design
Every table that contains tenant-specific data has a tenantId foreign key:
// prisma/schema.prisma
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
createdAt DateTime @default(now())
users User[]
courses Course[]
enrollments Enrollment[]
}
model User {
id String @id @default(cuid())
email String
role UserRole @default(STUDENT)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([email, tenantId]) // Same email allowed across tenants
@@index([tenantId])
}
model Course {
id String @id @default(cuid())
title String
description String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
modules Module[]
@@index([tenantId])
}
Key design decision: email + tenantId is the unique constraint, not just email. This allows the same email address to exist in multiple tenants (which is common in real-world scenarios).
Tenant Resolution: How Does the App Know Which Tenant?
The first architectural question: how does an incoming request know which tenant it belongs to?
I implemented subdomain-based tenant resolution:
acme.smartlms.app→ Tenant: ACME Corptechschool.smartlms.app→ Tenant: Tech School Incapi.smartlms.app/acme/...→ REST API with tenant slug in path
Middleware Implementation (Next.js)
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
// Skip for main domain and API
if (['www', 'api', 'app', 'admin'].includes(subdomain)) {
return NextResponse.next();
}
// Resolve tenant from subdomain
const tenant = await resolveTenant(subdomain);
if (!tenant) {
return NextResponse.redirect(new URL('/not-found', request.url));
}
// Inject tenant ID into headers for downstream use
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', tenant.slug);
return response;
}
The Tenant Context Provider
Once the tenant is resolved, I propagate it through the React tree using a context:
// lib/tenant-context.tsx
import { createContext, useContext } from 'react';
interface TenantContextValue {
tenantId: string;
tenantSlug: string;
tenantName: string;
plan: 'FREE' | 'PRO' | 'ENTERPRISE';
}
const TenantContext = createContext<TenantContextValue | null>(null);
export function TenantProvider({
tenant,
children
}: { tenant: TenantContextValue; children: React.ReactNode }) {
return (
<TenantContext.Provider value={tenant}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const ctx = useContext(TenantContext);
if (!ctx) throw new Error('useTenant must be used within TenantProvider');
return ctx;
}
Prisma with Tenant Isolation: The Repository Pattern
Every database query must include tenantId. Forgetting this even once is a data leak. I solved this with a tenant-scoped repository layer:
// lib/repositories/course-repository.ts
export class CourseRepository {
constructor(private tenantId: string) {}
async findAll() {
return prisma.course.findMany({
where: { tenantId: this.tenantId }, // Always scoped
include: { modules: true },
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string) {
const course = await prisma.course.findFirst({
where: {
id,
tenantId: this.tenantId, // Prevents cross-tenant access
},
});
if (!course) throw new NotFoundError(`Course ${id} not found`);
return course;
}
async create(data: CreateCourseInput) {
return prisma.course.create({
data: {
...data,
tenantId: this.tenantId, // Always injected
},
});
}
}
// Usage in API route
export async function GET(request: NextRequest) {
const tenantId = request.headers.get('x-tenant-id')!;
const repo = new CourseRepository(tenantId);
const courses = await repo.findAll();
return NextResponse.json(courses);
}
Authentication: Tenant-Aware JWT
I used NextAuth.js with a custom JWT strategy that embeds the tenantId:
// auth.config.ts
export const authOptions: NextAuthOptions = {
callbacks: {
async jwt({ token, user }) {
if (user) {
token.tenantId = user.tenantId;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
session.user.tenantId = token.tenantId as string;
session.user.role = token.role as UserRole;
return session;
},
async signIn({ user, account }) {
// Verify user belongs to the tenant they're signing into
const requestTenantId = getCurrentTenantId();
if (user.tenantId !== requestTenantId) {
return false; // Block cross-tenant login
}
return true;
}
}
};
Role-Based Access Control
Three roles within each tenant:
| Role | Permissions |
|------|------------|
| ADMIN | Full tenant management, all CRUD |
| INSTRUCTOR | Create/edit courses, view enrollments |
| STUDENT | View enrolled courses, track progress |
// lib/auth/rbac.ts
const PERMISSIONS = {
ADMIN: ['course:create', 'course:edit', 'course:delete', 'user:manage', 'tenant:settings'],
INSTRUCTOR: ['course:create', 'course:edit', 'enrollment:view'],
STUDENT: ['course:view', 'enrollment:self', 'progress:track'],
} as const;
export function can(role: UserRole, permission: string): boolean {
return PERMISSIONS[role]?.includes(permission) ?? false;
}
// Middleware guard for API routes
export function requirePermission(permission: string) {
return async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!can(session?.user.role, permission)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
};
}
Plan-Based Feature Gating
Different subscription tiers unlock different features:
const PLAN_LIMITS = {
FREE: { maxCourses: 3, maxStudents: 50, videoStorage: '5GB' },
PRO: { maxCourses: 50, maxStudents: 1000, videoStorage: '100GB' },
ENTERPRISE: { maxCourses: -1, maxStudents: -1, videoStorage: 'unlimited' },
};
async function checkPlanLimit(tenantId: string, resource: keyof typeof PLAN_LIMITS.FREE) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
const limit = PLAN_LIMITS[tenant!.plan][resource];
if (limit === -1) return true; // Unlimited
const current = await getCurrentUsage(tenantId, resource);
return current < limit;
}
Deployment Architecture
Vercel Edge Network
│
▼
Next.js Middleware (Tenant Resolution)
│
▼
Next.js App (App Router)
│
┌────┴────┐
│ │
▼ ▼
API Routes Server Components
│ │
└────┬────┘
│
▼
Prisma ORM → PostgreSQL (Supabase)
Lessons Learned
- Design multi-tenancy on day one — retrofitting it is enormously painful
- Repository pattern saves you from data leaks — centralizing
tenantIdscoping prevents accidental cross-tenant queries - Test with multiple tenants in staging — bugs only appear when data exists across tenants
- Subdomain routing needs DNS planning — wildcard DNS (
*.smartlms.app) must be set up early - Plan limits = business logic — enforce them in the service layer, not just the UI
Explore Smart LMS: Portfolio Page | GitHub