For when the docs run out.
Lantern is a self-hosted course platform. Instructors author video courses with modules, lessons and quizzes; students buy them at purchasing-power-adjusted prices and work through them; instructors and admins get analytics on sales, reach, completion and sentiment.
It is a server-rendered React Router 7 application backed by SQLite, with all business logic in a tested service layer.
- Features
- Tech stack
- Getting started
- Scripts
- Project structure
- Data model
- Architecture notes
- Testing
- Database migrations
- Limitations
Learning
- Courses organised into modules and lessons, with Markdown content, YouTube video, an optional linked GitHub repo and a per-lesson quiz.
- Progress tracking from video watch events through to lesson completion; completing the final lesson completes the course.
- Quizzes with multiple-choice and true/false questions, scored against a per-quiz passing threshold, with attempt history.
- Two-level threaded comments on every lesson, soft-deleted so moderation does not orphan replies.
- Star ratings, one per student per course.
Authoring
- Course creation with drag-and-drop ordering of modules, lessons and quiz questions.
- Lesson editing in an embedded Monaco editor with live Markdown preview and Shiki-highlighted code blocks.
- Draft / published / archived course states.
- Per-course student roster with progress.
Commerce
- Purchasing power parity pricing: four country tiers (0%, 30%, 50%, 70% off), configurable per course.
- PPP abuse protection — a discounted purchase is bound to the country it was made from, and access is blocked when it is opened from a different one.
- Team purchases: buying in bulk issues redeemable coupon codes, redeemed at
/redeem/:code, which enroll the redeemer.
Analytics
Per course, per instructor, and platform-wide:
| Panel | Metrics |
|---|---|
| Sales | Revenue after PPP discounts, purchases split individual vs. team |
| Reach | Enrollments, students not started, team seats sold vs. redeemed |
| Progress | Completion rate, plus a drop-off funnel showing where students stop |
| Sentiment | Average rating and star distribution |
| Trends | Revenue, enrollments and new users over 30d / 90d / all-time, bucketed daily or weekly |
| Layer | Choice |
|---|---|
| Framework | React Router 7 (framework mode, SSR) |
| UI | React 19, Tailwind CSS 4, shadcn/ui + Radix, Lucide |
| Language | TypeScript (strict) |
| Database | SQLite via Drizzle ORM and better-sqlite3 |
| Charts | Recharts |
| Content | Monaco editor, marked, Shiki |
| Validation | Zod |
| Testing | Vitest |
| Package manager | pnpm |
- Node.js 22 or newer
- pnpm 9 (
corepack enablewill provide it)
git clone https://github.com/BiLaL-159/Lantern.git
cd Lantern
pnpm install
pnpm db:seed # runs migrations, then seeds demo data
pnpm devThe app runs at http://localhost:5173.
Seeded accounts cover all three roles. Log in with an email address alone — there are no passwords (see Limitations):
| Role | |
|---|---|
| Admin | alex.rivera@ralph.dev |
| Instructor | sarah.chen@ralph.dev |
| Student | emma.wilson@student.dev |
In development, a floating panel in the corner switches between seeded users and overrides the detected country, which is the quickest way to see role-specific views and PPP pricing side by side.
| Command | Description |
|---|---|
pnpm dev |
Start the development server |
pnpm build |
Production build |
pnpm start |
Serve the production build |
pnpm test |
Run the test suite once |
pnpm test:watch |
Run tests in watch mode |
pnpm typecheck |
Generate route types, then run tsc |
pnpm db:generate |
Generate a migration from schema changes |
pnpm db:migrate |
Apply pending migrations |
pnpm db:seed |
Migrate and seed the database |
app/
├── routes/ Route modules — loaders read, actions write
├── services/ Database access and business logic, one module per domain concept
├── db/ Drizzle schema and client
├── lib/ Pure logic: PPP pricing, sessions, trend windows, authorisation
├── components/ Shared UI, including chart and analytics primitives
│ └── ui/ shadcn/ui primitives
└── app.css Tailwind entry point and theme tokens
drizzle/ Generated SQL migrations
scripts/seed.ts Demo data seeder
Nineteen tables, defined in app/db/schema.ts:
- Identity —
users,teams,team_members - Catalogue —
courses,categories,modules,lessons - Learning —
enrollments,lesson_progress,video_watch_events - Assessment —
quizzes,quiz_questions,quiz_options,quiz_attempts,quiz_answers - Commerce —
purchases,coupons - Feedback —
course_ratings,lesson_comments
- Routes never query the database. A route module loads through a service and renders; the service owns the SQL. This keeps business logic testable without a browser.
- Analytics are computed live. Funnels, trends and rollups are aggregate SQL queries against SQLite — nothing is precomputed or cached.
- Authorisation is centralised.
requireCourseAccess(app/lib/courseAccess.ts) performs the signed-in → role → course-exists → ownership checks, in order and with correct status codes, for every instructor route. - The client bundle is guarded by a test. Importing a runtime value from a service pulls
better-sqlite3, a native Node module, into the browser bundle and breaks the route.app/components/client-safety.test.tsfails if that regression returns; shared constants live inapp/lib/trends.tsinstead.
pnpm test376 tests across 17 files, covering the service layer and pure logic. Each test gets a fresh in-memory SQLite database built from the same migrations as the live one (app/test/setup.ts), so the schema under test can never drift from the schema that ships, and the suite runs in a few seconds without touching data.db.
The schema in app/db/schema.ts is the source of truth. After changing it:
pnpm db:generate # writes a new SQL file to drizzle/
pnpm db:migrate # applies itMigrations are checked in and applied in order. They may carry data as well as schema — 0005_backfill_course_completion.sql retroactively completes enrollments that already qualified when course completion was introduced.
This is a demonstration application, not a production deployment:
- Authentication is email-only. Sessions and role-based authorisation are real, but there are no passwords, hashing or email verification.
- Checkout is simulated. Purchases, discounts, team seats and coupon redemption work end to end as records; no payment provider is integrated.
- SQLite is a local file, suited to single-node use.
No license is currently granted for reuse.