← Back to tech insights

February 19, 2026 · 13 min

Smart LMS SaaS: Lessons in Multi-Tenant Course Management

A full case study of Smart LMS — the problem, architecture decisions, multi-tenancy design, the challenges we hit, and what I'd do differently building a SaaS learning management system.

Smart LMS SaaS: Lessons in Multi-Tenant Course Management

TL;DR: Smart LMS is a production-ready SaaS Learning Management System. This is the full story — from the initial problem analysis through architecture, implementation challenges, and the lessons I'd apply to the next SaaS product.


The Problem

The LMS market is dominated by legacy products (Moodle, Blackboard) or expensive enterprise solutions (Canvas, Cornerstone). Startups, bootcamps, and independent educators have limited good options that are:

  • Affordable for small organizations (< 500 students)
  • Modern UX — built with current web tech, not 2008-era UI
  • Developer-friendly — easy to integrate with existing tools
  • Self-service — no sales process to sign up

Smart LMS was designed to fill this gap.


User Research Before Code

Before writing a line of code, I spent two weeks talking to potential users:

Target personas:

  1. Bootcamp operators — 20–200 students, need cohort management, assignment grading, video content
  2. Independent instructors — 1–10 courses, self-enrollment, need revenue tracking
  3. Corporate L&D teams — Mandatory compliance training, completion certificates, reporting

Each persona had radically different needs. I had to make hard scoping decisions:

MVP scope:

  • ✅ Multi-tenant organization management
  • ✅ Course creation with text/video modules
  • ✅ Student enrollment and progress tracking
  • ✅ Instructor/admin/student role system
  • ✅ Certificate generation on completion
  • ❌ Live sessions (Zoom integration) — deferred
  • ❌ Marketplace/public course listing — deferred
  • ❌ AI-powered features — deferred to v2

This scoping prevented scope creep and let me ship in 12 weeks.


Architecture: The Core Decisions

Decision 1: Row-Level Tenancy

I chose shared-schema multi-tenancy with tenantId on every table. The main reasons:

  • Simpler operations — one database to manage vs. N per tenant
  • Easier analytics — can query across tenants for platform health
  • Cost-efficient — no overhead from idle per-tenant databases

The risk — accidental cross-tenant data exposure — was mitigated by the repository pattern (every query goes through a scoped repository class):

// Safe: every repository is scoped to a tenant at construction
class CourseRepository {
  constructor(private tenantId: string) {}
  
  findAll() {
    return prisma.course.findMany({ where: { tenantId: this.tenantId } });
  }
}

// Usage in API route
const repo = new CourseRepository(req.tenant.id);
// All queries are automatically scoped — impossible to "forget" tenantId

Decision 2: Next.js App Router with Server Components

The App Router's RSC model changed how I thought about data fetching:

  • No useEffect + fetch for page data — just async server components
  • No loading spinners for navigation — streaming SSR handles this
  • Simplified auth flowgetServerSession in server components, no client auth state
// Course list page — no useEffect, no client state, no loading spinner
// app/(dashboard)/courses/page.tsx
export default async function CoursesPage() {
  const tenant = await getServerTenant();
  const repo = new CourseRepository(tenant.id);
  const courses = await repo.findAll();
  
  return <CourseGrid courses={courses} />;
}

Decision 3: Stripe for Billing from Day One

I integrated Stripe in week 2 — before most product features were done. This forced me to:

  • Define plan tiers and their limits early
  • Build plan enforcement logic into every feature as I built it
  • Test the upgrade/downgrade flow

Deferring billing to "after MVP" almost always means painful retrofitting later.


The Data Model

// Core multi-tenant models

model Organization {
  id        String   @id @default(cuid())
  name      String
  slug      String   @unique  // Subdomain: slug.smartlms.app
  plan      Plan     @default(FREE)
  stripeCustomerId String?
  
  users     User[]
  courses   Course[]
  settings  OrgSettings?
}

model User {
  id         String   @id @default(cuid())
  email      String
  role       Role     @default(STUDENT)
  orgId      String
  org        Organization @relation(fields: [orgId], references: [id])
  
  enrollments Enrollment[]
  
  @@unique([email, orgId])  // Same email can exist in multiple orgs
  @@index([orgId])
}

model Course {
  id          String   @id @default(cuid())
  title       String
  description String   @db.Text
  status      CourseStatus @default(DRAFT)
  orgId       String
  instructorId String
  
  org         Organization @relation(fields: [orgId], references: [id])
  instructor  User         @relation(fields: [instructorId], references: [id])
  modules     Module[]
  enrollments Enrollment[]
  
  @@index([orgId])
  @@index([orgId, status])
}

model Enrollment {
  id         String   @id @default(cuid())
  userId     String
  courseId   String
  orgId      String
  status     EnrollmentStatus @default(ACTIVE)
  progress   Float    @default(0)  // 0-100
  enrolledAt DateTime @default(now())
  completedAt DateTime?
  
  @@unique([userId, courseId])  // No duplicate enrollments
  @@index([orgId])
}

Feature Breakdown: What I Built

Course Builder

The course builder was the most UX-intensive feature. Instructors need to:

  • Create modules (text, video, quiz)
  • Reorder modules via drag-and-drop
  • Preview as a student would see it
  • Publish/unpublish individual modules
// Drag-and-drop module reordering with optimistic updates
function ModuleList({ courseId, initialModules }: Props) {
  const [modules, setModules] = useState(initialModules);
  
  const handleDragEnd = async (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    
    // Optimistic update — reorder locally immediately
    const reordered = arrayMove(modules, 
      modules.findIndex(m => m.id === active.id),
      modules.findIndex(m => m.id === over.id)
    );
    setModules(reordered);
    
    // Persist to server
    try {
      await updateModuleOrder(courseId, reordered.map((m, idx) => ({
        id: m.id, 
        position: idx 
      })));
    } catch {
      setModules(initialModules); // Revert on failure
      toast.error('Failed to save module order');
    }
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={modules.map(m => m.id)}>
        {modules.map(m => <SortableModule key={m.id} module={m} />)}
      </SortableContext>
    </DndContext>
  );
}

Progress Tracking

Progress is tracked at the module level, aggregated to course level:

async function markModuleComplete(userId: string, moduleId: string, courseId: string) {
  // Record completion
  await prisma.moduleCompletion.upsert({
    where: { userId_moduleId: { userId, moduleId } },
    create: { userId, moduleId, completedAt: new Date() },
    update: { completedAt: new Date() },
  });
  
  // Recalculate course progress
  const [totalModules, completedModules] = await Promise.all([
    prisma.module.count({ where: { courseId } }),
    prisma.moduleCompletion.count({ 
      where: { 
        userId, 
        module: { courseId } 
      } 
    }),
  ]);
  
  const progress = (completedModules / totalModules) * 100;
  
  await prisma.enrollment.update({
    where: { userId_courseId: { userId, courseId } },
    data: { 
      progress,
      completedAt: progress === 100 ? new Date() : null,
    },
  });
  
  // Trigger certificate generation if complete
  if (progress === 100) {
    await generateCertificate(userId, courseId);
  }
}

Certificate Generation

Certificates are generated as PDFs using @react-pdf/renderer:

import { Document, Page, Text, View, Image } from '@react-pdf/renderer';

function CourseCertificate({ studentName, courseName, completedDate, instructorName }: Props) {
  return (
    <Document>
      <Page size="A4" orientation="landscape" style={styles.page}>
        <View style={styles.border}>
          <Image src="/certificate-header.png" style={styles.logo} />
          
          <Text style={styles.title}>Certificate of Completion</Text>
          
          <Text style={styles.body}>This certifies that</Text>
          <Text style={styles.name}>{studentName}</Text>
          <Text style={styles.body}>has successfully completed</Text>
          <Text style={styles.course}>{courseName}</Text>
          
          <View style={styles.footer}>
            <Text style={styles.date}>{completedDate}</Text>
            <Text style={styles.instructor}>{instructorName}</Text>
          </View>
        </View>
      </Page>
    </Document>
  );
}

// API endpoint: generate and store PDF
export async function GET(request: Request, { params }: { params: { enrollmentId: string } }) {
  const enrollment = await getEnrollmentWithDetails(params.enrollmentId);
  
  if (enrollment.progress < 100) {
    return new Response('Course not completed', { status: 400 });
  }
  
  const pdfBuffer = await renderToBuffer(
    <CourseCertificate
      studentName={enrollment.user.name}
      courseName={enrollment.course.title}
      completedDate={formatDate(enrollment.completedAt)}
      instructorName={enrollment.course.instructor.name}
    />
  );
  
  return new Response(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="certificate-${params.enrollmentId}.pdf"`,
    },
  });
}

The Challenges

Challenge 1: Video Hosting

I initially planned to host video uploads on S3. After pricing it out, a multi-tenant platform serving video would incur massive egress costs.

Solution: Integrate with Cloudflare Stream for video hosting. $5/1000 minutes stored, $1/1000 minutes delivered. For most organizations, cost is near zero.

Challenge 2: Email Deliverability

Sending emails from a shared tenant domain caused spam classification. Many enrollment confirmations landed in spam.

Solution: Mandated custom domain verification (DKIM/SPF) for organizations sending more than 50 emails/month. Using Resend's dedicated infrastructure.

Challenge 3: Plan Enforcement Consistency

I had plan limits enforced in UI (button disabled), API routes, and server actions. They kept going out of sync as I added features.

Solution: Single source of truth — a canPerformAction function called everywhere:

// lib/plan-enforcement.ts — single source of truth
export async function canPerformAction(
  tenantId: string,
  action: 'createCourse' | 'enrollStudent' | 'uploadVideo' | 'addInstructor'
): Promise<{ allowed: boolean; reason?: string }> {
  const tenant = await getTenant(tenantId);
  const limits = PLAN_LIMITS[tenant.plan];
  const usage = await getCurrentUsage(tenantId);
  
  switch (action) {
    case 'createCourse':
      if (limits.maxCourses !== -1 && usage.courses >= limits.maxCourses) {
        return { allowed: false, reason: `Your plan allows ${limits.maxCourses} courses` };
      }
      break;
    case 'enrollStudent':
      if (limits.maxStudents !== -1 && usage.students >= limits.maxStudents) {
        return { allowed: false, reason: `Your plan allows ${limits.maxStudents} students` };
      }
      break;
  }
  
  return { allowed: true };
}

What I'd Do Differently

  1. Start with Stripe earlier — I started with Stripe in week 2, and it still felt late
  2. Build email templates with real design from the start — I had plain text emails for 3 weeks and regretted it
  3. Write E2E tests for enrollment flows — this broke silently twice and I found out from users
  4. Design the admin panel earlier — I couldn't debug production issues well without it
  5. More time on the course builder UX — it's the core product experience and I underinvested

Results

After 4 months of use by 3 beta organizations:

  • 127 students enrolled across 14 courses
  • 89 certificates issued
  • 0 cross-tenant data incidents
  • Avg session time: 23 minutes (healthy engagement signal)

Explore Smart LMS: Portfolio | GitHub