Open-source LMS reference built on FastPix Video Data. Postgres + NextAuth, multi-tenant by design, with three engagement signals layered on top of the FastPix SDK.
Status: reference implementation. This is a working, production-shaped example you can fork and build on. It is not a hosted product. There is no SLA. Read What this is NOT before deploying.
Owner side (auth-gated, magic-link email):
- Sign in via emailed magic link (no passwords)
- Create courses, add lessons by pasting a FastPix playback ID
- Per-course dashboard showing students, engagement curves, and at-risk learners
- Remove individual lessons, students, or entire courses (with cascading cleanup)
- Sign out clears the session in Postgres + the browser cookie
Student side (anonymous, no signup):
- Visit a public course URL (
/learn/<slug>) - Enter a display name once → server-side dedup means the same name across two browsers maps to one student record
- Watch lessons via the FastPix player; telemetry flows in the background
- Returning visitors see a "welcome back" and can jump to any lesson
- Placeholder "🎓 Certificate" button (stub — fork-friendly to wire up)
Three engagement signals computed from heartbeats + the FastPix Data API:
- Per-segment engagement curve — 30-second buckets of watched-seconds, replays, and backward seeks. Surfaces where attention dips inside a lesson.
- Manifest-level drop-off —
playing_time × video_encoding_variantfrom the FastPix Data API. Flags encoding-related abandonment. - Per-user struggle score —
replay_density × 0.4 + seek_back_density × 0.6, with an absolute-events floor so short videos don't false-positive.
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router, Server Actions) |
| UI | React 18 + Tailwind CSS |
| Database | Postgres 16 (via Docker for local dev) |
| ORM | Prisma 5 |
| Auth | NextAuth.js 4 (magic-link email, DB sessions) |
| Email (dev) | MailHog (via Docker, catches all SMTP locally) |
| Input validation | Zod |
| Video | @fastpix/fp-player (web component) + @fastpix/video-data-core |
| Lint / format | ESLint + Prettier |
Zero front-end JS framework outside React. Zero CSS framework outside Tailwind. Zero secret-management dependency beyond .env. The dependency graph is deliberately minimal — easier to audit, easier to keep on a current version.
- Not a hosted product. You run it yourself.
- Not a multi-tenant SaaS template. Each user owns their own courses; there are no orgs, no teams, no billing.
- Not feature-complete vs. a real LMS. No quizzes, no certificates (only a placeholder button), no DRM, no live streams, no payments.
- Not maintained on a fixed cadence. PRs welcome for the reference patterns; feature requests will likely be declined.
Requires Node 18.17+, Docker (for local Postgres + SMTP), openssl (for generating the secret), and a FastPix workspace for the player.
# 1. Clone & install
git clone https://github.com/fastpix/fastpix-lms-oss.git
cd fastpix-lms-oss
npm install
# 2. Start Postgres + MailHog via Docker (~10s on first run, instant after)
docker compose up -d
docker compose ps # confirm both containers show "Up" / "(healthy)"
# 3. Configure env — note: `.env`, NOT `.env.local`. See "Env file naming" below.
cp .env.example .env
# Then edit `.env` and fill in:
# - NEXTAUTH_SECRET: run `openssl rand -base64 32`, paste the output
# - NEXT_PUBLIC_FASTPIX_WORKSPACE_KEY: from FastPix dashboard → Settings → Workspaces
# - (optional) FASTPIX_ACCESS_TOKEN_ID + FASTPIX_SECRET_KEY: from FastPix
# dashboard → Settings → Access Tokens. Required only for the manifest-level
# drop-off chart on the dashboard. Leave blank if you don't need it.
# Defaults for DATABASE_URL, NEXTAUTH_URL, and the MailHog SMTP settings
# already work — don't change them unless you know why.
# 4. Generate Prisma client + apply migrations
npx prisma generate
npm run db:migrate
# When prompted for a migration name, type `init` and press Enter.
# 5. Start the dev server
npm run devOpen http://localhost:3000, click Sign in as owner, enter any email. The magic-link email appears at http://localhost:8025 (MailHog's web UI). Click the link in that email and you're signed in.
After you sign in, you'll land on Your courses (empty). Here's how to fill it:
- Click + New course, give it a title, submit. You're now on the course detail page.
- Click Add a lesson. Paste a FastPix playback ID (find one in FastPix dashboard → Media → click any asset → "Playback IDs"). Add a title and the video duration in seconds. Submit.
- Click View public page ↗ (top-right). It opens the student-facing course page in a new tab.
- In that new tab, you're the student. Enter a name (e.g., "Test") and click "Start course." The player loads.
- Watch for at least 30 seconds (one heartbeat bucket fires every 30s).
- Switch back to your owner tab and click Dashboard. You should see your test student in the table with a non-zero completion %, and a dot on the engagement curve.
That's the full loop. From here you can add more lessons, test as multiple students in different browsers, and watch the at-risk classifier kick in if you abandon a video early.
We use .env (not Next.js's conventional .env.local) because Prisma's CLI does not read .env.local. Keeping a single .env file avoids the "Prisma can't find DATABASE_URL" footgun. Both Next.js and Prisma read .env happily, and it's gitignored.
If you forked an older copy of this repo that referenced .env.local, cp .env.local .env and you're good.
┌──────────────────┐ heartbeat ┌──────────────────┐
│ CourseFpPlayer │ ─────────────────▶ │ /api/heartbeat │
│ (browser) │ (idempotent POST) │ (Zod + Prisma) │
└──────────────────┘ └──────────┬───────┘
│ │
│ <fastpix-player> ▼
│ + segment-tracker ┌──────────────┐
▼ │ Postgres │
┌──────────────────┐ ◀─────────────────────│ Heartbeat │
│ FastPix Data │ /v1/data/... │ table │
│ (view metrics + │ (server-side fetch └──────────────┘
│ drop-off API) │ in dashboard) ▲
└──────────────────┘ │
│
┌─────────────┴────────┐
│ lib/signals.ts │
│ (pure functions) │
└──────────────────────┘
The signal computations live in lib/signals.ts as pure functions — they take heartbeat rows and return derived metrics. No DB access, no FastPix coupling. Easy to unit-test, easy to lift into your own LMS without taking the rest of the project.
app/
├─ api/ # route handlers
│ ├─ auth/[...nextauth]/ # NextAuth endpoints (auto-generated)
│ ├─ heartbeat/ # POST /api/heartbeat (Zod + idempotent upsert)
│ └─ student-session/ # POST /api/student-session (name-based dedup)
├─ owner/ # auth-gated owner area
│ ├─ page.tsx # courses list
│ ├─ new/page.tsx # create course form
│ └─ [id]/
│ ├─ page.tsx # course detail (lessons, add/remove, delete course)
│ └─ dashboard/page.tsx
├─ learn/[id]/ # public student area (no auth)
│ ├─ page.tsx # course landing + name prompt
│ └─ watch/[videoId]/ # player page
├─ signin/page.tsx # magic-link entry
├─ page.tsx # marketing home
└─ layout.tsx # root layout (header + global styles)
actions/courses.ts # server actions: create/delete course, add/remove lesson, remove student
components/ # React components (CourseFpPlayer, StudentNamePrompt, SiteHeader, etc.)
lib/ # pure helpers (signals, segment-tracker, auth, db, fastpix client, rate-limit, zod schemas)
prisma/ # schema + generated migrations
| File | What it does |
|---|---|
prisma/schema.prisma |
Data model: User (NextAuth), Course, Video, Student, ViewerSession, Heartbeat. Unique constraint on (sessionClientId, segmentIndex) makes heartbeat ingestion idempotent. |
lib/segment-tracker.ts |
Client-side 30s heartbeat tracker. Handles shadow-DOM, sub-30s videos, replay/seek dedup, idempotency. |
lib/signals.ts |
Pure signal computation: engagement curve, struggle score, at-risk classification. Easy to lift out. |
lib/fastpix.ts |
Server-side FastPix Data API client (basic auth). |
lib/auth.ts |
NextAuth config + Prisma adapter + requireUser() server helper. |
lib/rate-limit.ts |
In-memory token-bucket limiter for the heartbeat endpoint. Swap for Redis in production. |
app/api/heartbeat/route.ts |
Idempotent POST. Rate-limited. Validates the (student, video) pair is in the same course. |
app/api/student-session/route.ts |
Server-side dedup by (courseId, name) so the same student on two browsers maps to one record. |
actions/courses.ts |
Server actions for course/lesson/student mutations. Every action does an ownership check. |
components/CourseFpPlayer.tsx |
Shadow-DOM-aware player wrapper that attaches the tracker. |
components/SiteHeader.tsx |
Path-aware nav. Owner vs. student vs. marketing chrome. Sign-out button. |
components/StudentNamePrompt.tsx |
Anonymous student onboarding + returning-visitor handling. |
app/owner/[id]/dashboard/page.tsx |
The dashboard — at-risk list, student table, per-video engagement curve. |
npm run dev # start the dev server
npm run build # production build
npm run start # run the production build
npm run typecheck # tsc --noEmit
npm run lint # next lint
npm run format # prettier --write
npm run db:migrate # prisma migrate dev
npm run db:deploy # prisma migrate deploy (use in production)
npm run db:studio # open Prisma Studio in the browserCompared to a full LMS, the following are absent on purpose to keep the OSS surface minimal:
- Excel/CSV exports — straightforward to add, but pulls in a heavy dependency. See the fastpix-video-data-lms-playbook demo for an example using
exceljs. - Quizzes, certificates, grades — out of scope. There's a placeholder Certificate button on student pages, wired to nothing. Implement in your fork.
- Multi-org / teams — single owner per course.
- Webhooks → warehouse — for production scale you'd write heartbeats to Kafka/Kinesis/PubSub and aggregate in BigQuery/ClickHouse instead of querying Postgres directly. The pattern is the same; only the storage layer changes.
zsh: command not found: docker → Docker Desktop isn't installed or isn't in your PATH. Install from https://www.docker.com/products/docker-desktop/, open the app once so the daemon starts (whale icon should be solid in your menu bar), then retry.
Homebrew permission errors when installing Docker on Apple Silicon → run sudo chown -R $(whoami) /opt/homebrew and retry. This happens when prior brew operations left the directory owned by a different user.
npm install fails with ERESOLVE on nodemailer → make sure you're on the version of package.json from the current main (we use nodemailer 7 to satisfy next-auth's peer dep). If the error persists, npm install --legacy-peer-deps.
MailHog logs a platform warning (linux/amd64 does not match host platform linux/arm64) on Apple Silicon → harmless. MailHog doesn't ship an ARM image; Docker emulates it under Rosetta. Slower but works.
Magic-link email doesn't appear in MailHog → check three things in .env:
EMAIL_SERVER_HOSTmust belocalhost(notsmtp.example.com)EMAIL_SERVER_PORTmust be1025(not587)EMAIL_FROMmust be set to anything non-empty
You also need to restart npm run dev after changing these — Next.js doesn't hot-reload server env vars.
Environment variable not found: DATABASE_URL when running prisma migrate → you have a .env.local instead of .env. Rename or copy: cp .env.local .env. See "Env file naming" above.
Port 3000 is already in use → some other dev server is running. Kill it:
lsof -ti :3000 | xargs killOr change NEXTAUTH_URL in .env to whatever port Next.js falls back to (usually 3001).
The player area is blank when a student opens a lesson → most likely NEXT_PUBLIC_FASTPIX_WORKSPACE_KEY isn't set. This is a public env var that gets baked into the client bundle at build time, so you also need to restart npm run dev after setting it. Confirm in the browser console — there should be a [CourseFpPlayer] attached segment tracker to inner <video> log within ~3s of page load.
Heartbeats don't appear in the dashboard even after watching for 30s+ → check the dev server terminal for POST /api/heartbeat 200. If you see 403 scope_mismatch, clear localStorage for localhost:3000 (DevTools → Application → Local Storage) — you have a stale student ID from a different course. If you see 400 invalid_payload, the schema and the client tracker have drifted; re-run npx prisma generate.
If you do deploy this, here's what you need to harden first:
- Swap the in-memory rate limiter (
lib/rate-limit.ts) for Redis (Upstash, KV). - Move heartbeat ingestion off the main DB (Kafka/SQS → batched writes).
- Add an allow-list to NextAuth's
signIncallback if you don't want anyone-with-an-email signing in. - Replace
findManyheartbeats in the dashboard with windowed/aggregated SQL. - Add observability — at minimum, structured logs + an error reporter (Sentry, etc.).
- Add DB connection pooling (PgBouncer / Prisma Accelerate) for serverless deployments.
- Switch SMTP from MailHog to a real provider (Resend, SendGrid, SES).
- Set
NEXTAUTH_URLto your real production URL.
Apache 2.0. Use it, fork it, ship it.
FastPix publishes this repository as a reference implementation. It is provided "as is" without warranty of any kind. Bugs in this code do not reflect bugs in the FastPix Video Data SDK or hosted product — the signal computations (engagement curve, struggle score) are implemented in this repo and are subject to the same Apache 2.0 disclaimer as the rest of the code. If you find an issue with FastPix's hosted analytics product, report it via FastPix support, not this repo's issue tracker.