← Back to tech insights

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 Corp
  • techschool.smartlms.app → Tenant: Tech School Inc
  • api.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

  1. Design multi-tenancy on day one — retrofitting it is enormously painful
  2. Repository pattern saves you from data leaks — centralizing tenantId scoping prevents accidental cross-tenant queries
  3. Test with multiple tenants in staging — bugs only appear when data exists across tenants
  4. Subdomain routing needs DNS planning — wildcard DNS (*.smartlms.app) must be set up early
  5. Plan limits = business logic — enforce them in the service layer, not just the UI

Explore Smart LMS: Portfolio Page | GitHub