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:
- Bootcamp operators — 20–200 students, need cohort management, assignment grading, video content
- Independent instructors — 1–10 courses, self-enrollment, need revenue tracking
- 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+fetchfor page data — justasyncserver components - No loading spinners for navigation — streaming SSR handles this
- Simplified auth flow —
getServerSessionin 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
- Start with Stripe earlier — I started with Stripe in week 2, and it still felt late
- Build email templates with real design from the start — I had plain text emails for 3 weeks and regretted it
- Write E2E tests for enrollment flows — this broke silently twice and I found out from users
- Design the admin panel earlier — I couldn't debug production issues well without it
- 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)