Who this is for: You — the person running this SaaS LMS platform. This guide walks you through every step from zero to a live platform collecting students and money from schools.
- What You're Running
- Initial Setup
- Running Locally
- Deploying to Production
- Becoming the Super Admin
- The Platform Panel — Your Control Room
- Inviting Schools — The Referral System
- How a School Gets Started
- How Students Join and Learn
- How Payments Work
- Making Money — Platform Billing
- Day-to-Day Operations Checklist
- Troubleshooting
This is a multi-tenant SaaS LMS. Think of it like Shopify but for online schools:
Your Platform (lvh.me or yourdomain.com)
└── School A → schoola.yourdomain.com
├── Courses, lessons, exams
├── Their students (isolated — can't see School B's data)
└── Their revenue (Stripe Connect)
└── School B → schoolb.yourdomain.com
└── School C → schoolc.yourdomain.com
Three types of users:
| Role | Where they live | What they do |
|---|---|---|
| Super Admin | /platform |
You. Run the whole SaaS. |
| School Admin | /dashboard/admin |
Run their school, manage courses, approve payments. |
| Student / Teacher | /dashboard/student or /dashboard/teacher |
Take or create courses. |
How you make money:
- Schools pay you a monthly/yearly subscription (Free → $9 → $29 → $79 → $199/mo)
- You take a transaction fee on every student payment (10% free tier → 0% paid tiers)
- Node.js 20+
- A Supabase project (free tier is fine to start)
- A Stripe account with Connect enabled
- (Optional) A Mailgun account for transactional email
- (Optional) An OpenAI API key for AI features
- (Optional) MCP server for AI assistant integration (see
docs/MCP_SETUP.md)
git clone <your-repo>
cd lms-front
npm installCopy the example file and fill in every value:
cp .env.example .env.localOpen .env.local and fill in:
# ─── SUPABASE ───────────────────────────────────────────────────
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # KEEP SECRET — bypasses RLS
# ─── YOUR PLATFORM DOMAIN ───────────────────────────────────────
NEXT_PUBLIC_PLATFORM_DOMAIN=yourdomain.com
# For local dev, use:
# NEXT_PUBLIC_PLATFORM_DOMAIN=lvh.me:3000
# ─── STRIPE (student payments — Stripe Connect) ─────────────────
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_... # From your Connect webhook
# ─── STRIPE (school billing — Stripe Billing) ───────────────────
STRIPE_PLATFORM_WEBHOOK_SECRET=whsec_... # Different webhook!
# ─── AI (optional but recommended) ─────────────────────────────
OPENAI_API_KEY=sk-...
# ─── EMAIL (optional but recommended) ──────────────────────────
MAILGUN_API_KEY=key-...
MAILGUN_DOMAIN=mg.yourdomain.com
EMAIL_FROM=noreply@yourdomain.com
# ─── CERTIFICATES ───────────────────────────────────────────────
CERTIFICATE_ENCRYPTION_KEY=any-random-32-char-string
# ─── CRON (for subscription expiry) ────────────────────────────
CRON_SECRET=any-random-secret# Apply all migrations to your Supabase project
supabase db pushThis creates all 116 tables, RLS policies, indexes, RPCs, and triggers.
supabase functions deploy get-leaderboard
supabase functions deploy spend-points
supabase functions deploy get-plan-features
supabase functions deploy get-gamification-summary
supabase functions deploy check-achievementsnpm run dev
# → http://localhost:3000The platform uses subdomains for tenant isolation. Locally, lvh.me is a magic domain
that routes all subdomains to localhost:
| URL | What you see |
|---|---|
http://lvh.me:3000 |
Default school (tenant 00000000-0000-0000-0000-000000000001) |
http://myschool.lvh.me:3000 |
The myschool tenant |
http://lvh.me:3000/en/platform |
Super admin panel |
Make sure .env.local has:
NEXT_PUBLIC_PLATFORM_DOMAIN=lvh.me:3000| Password | Role | |
|---|---|---|
owner@e2etest.com |
password123 |
School admin (Default School) |
student@e2etest.com |
password123 |
Student (Default School) |
creator@codeacademy.com |
password123 |
Admin (Code Academy Pro) |
alice@student.com |
password123 |
Student (Code Academy Pro) |
# Connect repo to Vercel, then:
vercel --prodSet all environment variables from .env.example in Vercel's dashboard
under Project → Settings → Environment Variables.
For each school to get their own subdomain, you need a wildcard DNS record:
Type: A
Name: *
Value: 76.76.21.21 (Vercel's IP)
This makes anyschool.yourdomain.com resolve to your app.
Then in Vercel, add *.yourdomain.com as a custom domain for your project.
You need two separate webhooks in Stripe:
Webhook 1 — Student Payments (Stripe Connect):
Endpoint: https://yourdomain.com/api/stripe/webhook
Events: payment_intent.succeeded, charge.refunded, payout.paid, charge.dispute.created
→ Copy the signing secret to STRIPE_WEBHOOK_SECRET
Webhook 2 — School Billing (Stripe Billing):
Endpoint: https://yourdomain.com/api/stripe/platform-webhook
Events: checkout.session.completed, invoice.payment_failed, customer.subscription.deleted
→ Copy the signing secret to STRIPE_PLATFORM_WEBHOOK_SECRET
The vercel.json file already configures a daily cron job that expires lapsed
school subscriptions. Vercel runs this automatically on Pro+ plans.
The cron calls /api/cron/expire-subscriptions with your CRON_SECRET header.
This is the most important step. Without this, you can't access /platform.
Go to your live app and sign up with your email at /en/auth/signup.
In Supabase dashboard → Authentication → Users, find your email and copy the UUID.
In Supabase dashboard → SQL Editor, run:
INSERT INTO super_admins (user_id)
VALUES ('paste-your-uuid-here');Go to https://yourdomain.com/en/platform — you should see the platform overview
dashboard. If you're redirected to login, double-check the UUID matches.
Security note:
isSuperAdmin()queries thesuper_adminstable directly on every request — it never trusts the JWT. Only you can add rows to this table.
Everything at /platform is for you only. Schools and students can never access it.
Your business at a glance:
- MRR — sum of all active monthly subscriptions
- Active Schools — tenants currently on a paid plan
- New Schools (30d) — growth this month
- Total Students — across all tenants
- Pending Payments — manual bank transfers waiting for your confirmation
- Plan Distribution — bar chart showing Free / Starter / Pro / Business breakdown
Every school on your platform, with:
- Plan badge (Free / Starter / Pro / Business / Enterprise)
- Status (active / suspended)
- Student count, course count
- Monthly revenue contribution
Actions on each school:
| Action | What it does |
|---|---|
| View Details | See their full stats, subscription history, admin users |
| Impersonate User | Sign in as any user in that school (for support) |
| Change Plan | Force a plan upgrade/downgrade |
| Suspend | Disable school access (keeps data, blocks login) |
| Reactivate | Re-enable a suspended school |
When a school owner reports a bug or needs help:
- Go to
/platform/tenants→ find the school → click View Details - Click Impersonate User → pick the user from the dropdown
- Click Sign in as [Name] — you'll be redirected and logged in as them
- You see exactly what they see; a yellow banner shows "Impersonating [Name]"
- Click Exit Impersonation to return to your super admin session
Every impersonation is logged in the
impersonation_logtable for audit purposes.
When a school pays via bank transfer instead of Stripe, you confirm it here.
Tabs:
- Pending — needs your action (most important)
- Confirmed — done
- Rejected — declined
- All — full history
For each pending request:
- Check the bank reference against your bank statement
- Click Confirm — this activates their plan and extends their billing period
- Or Reject with a reason — they get notified
Edit your pricing tiers directly from the UI — no SQL needed.
You can change:
- Monthly and yearly prices
- Transaction fee percentages
- Feature limits (max courses, max students)
- Feature flags (gamification, custom branding, API access)
- Deactivate a plan to hide it from new signups (existing subscribers keep it)
Changes take effect immediately for new signups. Existing subscribers are NOT affected.
See the full referral funnel:
- Codes created vs. redeemed
- Referrer → referee relationships
- Pending rewards (referrer hasn't been rewarded yet because referee hasn't paid)
- Create custom platform-wide referral codes
This is your main growth channel. There are two types of referral codes.
Go to /platform/referrals → Generate Code → set:
- Code (e.g.
EARLYBIRD) - Discount months for new schools (e.g. 2 = 2 free months)
- Referrer reward months (0 for platform-generated codes)
- Max uses (or unlimited)
Share the link: https://yourdomain.com/en/create-school?ref=EARLYBIRD
When a school signs up using this link:
- They get 2 free months on their chosen plan
- You track the conversion in
/platform/referrals
School admins can get their own referral code from:
Settings → Referral Program (in /dashboard/admin/settings)
A card shows their referral URL: yourdomain.com/create-school?ref=ACMEX3F
When they share it and a new school signs up:
- New school gets 1 free month
- Referring school gets 1 free month when the new school makes their first payment
Rewards are only issued after the first real payment — prevents abuse.
1. School A shares link: yourdomain.com/create-school?ref=ACMEX3F
2. School B signs up using that link
→ referral_redemptions row created
→ School B's billing_period_end extended by discount_months (immediately)
3. School B makes first payment (Stripe or confirmed bank transfer)
→ applyReferralCode() runs
→ School A's billing_period_end extended by referrer_reward_months
→ referral_redemptions.referrer_rewarded set to true
They go to yourdomain.com/en/create-school — a unified create-school flow with cross-subdomain authentication that handles:
- School name
- Subdomain slug (e.g.
myacademy→myacademy.yourdomain.com) - Account creation or existing account login
This creates a tenants row and makes them the school admin.
They start on the Free plan (5 courses, 50 students, 10% transaction fee).
After school creation, admins are guided through an onboarding wizard that walks them through initial setup steps (branding, first course, payment configuration). This reduces the blank-slate experience and helps new schools get started quickly.
They log in at myacademy.yourdomain.com and land on /dashboard/admin.
First things to configure (Settings → General):
- School name, logo, description
- Contact email
- Default currency
Settings → Branding:
- Primary color (used throughout the student experience)
- Logo URL
Settings → Payment:
- Connect their Stripe account (for student payments via Stripe Connect)
- Or use manual bank transfer (you confirm each payment manually)
- Go to
/dashboard/admin/courses→ New Course - Fill in title, description, thumbnail
- Add lessons: click into the course → Add Lesson → use the block editor
- Text blocks (MDX)
- Video blocks (upload or YouTube embed)
- Exercise blocks (quizzes, code challenges)
- Set the course to Published when ready
A product is the thing students pay for. It can bundle multiple courses.
- Go to
/dashboard/admin/products→ New Product - Set price, currency, payment type (Stripe / manual / PayPal)
- Add courses to the product
- Publish it
Option A — Students find the school and join at myacademy.yourdomain.com
Option B — Admin uses the invitation system:
- Go to
/dashboard/admin/users→ Invite User → enter their email and role - They get an email invitation to join the school
- Invitations can be managed (resend, revoke) from the admin panel
Admins can build a public-facing landing page using the Puck v0.20 visual editor:
- Go to
/dashboard/admin/landing-page→ drag-and-drop editor with 32 components - Choose from 8 built-in templates (Modern Academy, Minimal, Bold Creator, etc.)
- Preview and publish — the landing page appears at the school's subdomain root
When they hit the student or course limit, they see a prompt to upgrade.
They go to /dashboard/admin/billing and choose:
- Stripe → instant, automated
- Bank Transfer → manual, you confirm at
/platform/billing
- Student goes to
myacademy.yourdomain.com - Clicks Sign Up or Log In → lands on
/join-school - Clicks Join — this calls
joinCurrentSchool():- Checks the school hasn't hit its student limit
- Inserts a
tenant_usersrow (role: student) - Refreshes their JWT to include
tenant_idandtenant_role - Sends them a welcome email (via Mailgun)
- They're redirected to
/dashboard/student
If the course is free: they click Enroll and they're in.
If the course requires payment:
Stripe flow:
- Student clicks Buy on a product
- Stripe PaymentIntent is created (platform takes its fee cut automatically)
- Student completes payment on the checkout page
- Stripe webhook fires →
enroll_user()RPC runs →enrollmentsrow created - Student can now access all courses in the product
Manual/Bank transfer flow:
- Student clicks Request Enrollment
- Creates a
payment_requestsrow (status: pending) - School admin sees it in
/dashboard/admin/payment-requests - Admin sends bank transfer instructions
- Student pays and marks as paid
- Admin confirms →
enroll_user()runs → enrollment active
Once enrolled:
/dashboard/student/courses— all enrolled courses- Each course shows progress (lessons completed / total)
- Lessons auto-mark complete after engagement
- Gamification: XP earned per lesson, leaderboard ranking, achievements unlocked
- Certificates: Auto-issued when all lessons in a course are complete
- Students can download or share their certificate;
/verify/[code]for public verification
Student pays School → Stripe Connect (PaymentIntents)
School pays Platform → Stripe Billing (Subscriptions/Checkout)
Do not confuse them — they have different webhook secrets!
- School connects their Stripe account in settings
- When a student pays, your platform creates a PaymentIntent with:
transfer_data.destination= school's Stripe accountapplication_fee_amount= platform transaction fee (based on school's plan)
- Money flows: Student → Platform → School (minus platform fee)
- Platform fee is automatically deposited to your Stripe account
| Plan | Fee on every sale |
|---|---|
| Free | 10% |
| Starter ($9/mo) | 5% |
| Pro ($29/mo) | 2% |
| Business ($79/mo) | 0% |
| Enterprise ($199/mo) | 0% |
School on Starter pays you $9/mo + 5% of every student sale. School on Business pays you $79/mo and keeps 100% of student sales.
Schools in LATAM often use bank transfers. The flow:
- Student requests enrollment (creates
payment_requestsrow) - School admin sends bank details via the admin panel
- Student transfers money to school's bank
- School admin confirms receipt → enrollment activated
As platform owner, you get your cut via the school's subscription fee (not per-transaction).
Schools have two options on the billing page (/dashboard/admin/billing):
Option A: Stripe (automated)
- School enters credit card → Stripe Checkout → subscription starts
- Renewals are automatic; failed payments trigger dunning emails
- Cancellations downgrade to Free immediately
Option B: Bank Transfer (manual)
- School submits a
platform_payment_requestsrow - You confirm at
/platform/billingafter checking your bank - Their plan upgrades and billing period extends
| Plan | Monthly | Yearly | Transaction Fee |
|---|---|---|---|
| Free | $0 | $0 | 10% |
| Starter | $9 | $90 | 5% |
| Pro | $29 | $290 | 2% |
| Business | $79 | $790 | 0% |
| Enterprise | $199 | $1,990 | 0% |
Yearly = ~17% discount. Good to push annual plans for predictable MRR.
School signs up → Free plan (no billing)
School upgrades → platform_subscriptions row created, tenants.plan updated
School's card fails → status = past_due, email sent
School's card fails repeatedly → status = canceled, plan → Free
School's subscription expires → daily cron job catches it, downgrades to Free
The cron job (/api/cron/expire-subscriptions) runs daily at 02:00 UTC automatically
on Vercel (configured in vercel.json).
- Open
/platform— check MRR trend and any alerts - Open
/platform/billing→ Pending tab — confirm any bank transfers waiting - Check email for any failed subscription notifications
/platform/tenants— review new schools from last 7 days- Check for schools on Free approaching limits (likely to upgrade)
/platform/referrals— see pending referral rewards to confirm
- Review plan distribution — are most schools on Free? Consider a promotion.
- Check churned schools (status: suspended/canceled)
- Export transaction history for accounting
- Update plan prices if needed at
/platform/plans
- Go to
/platform/tenants→ find their school → View Details - Click Impersonate User → select the affected user
- Reproduce the issue from their perspective
- Click Exit Impersonation when done
- Fix the issue or escalate to engineering
→ Your user UUID is not in the super_admins table.
-- In Supabase SQL editor:
SELECT user_id FROM super_admins;
-- If your UUID isn't listed:
INSERT INTO super_admins (user_id) VALUES ('your-uuid');→ Check the platform_payment_requests row status. If it's still pending,
the confirmation action failed. In Supabase, manually update:
UPDATE tenants SET plan = 'starter' WHERE id = 'tenant-uuid';
UPDATE platform_payment_requests SET status = 'confirmed' WHERE request_id = 'uuid';→ The webhook probably failed to fire. Check Supabase logs:
- Supabase Dashboard → Edge Functions → Logs (if using edge function webhook)
- Or check
/api/stripe/webhooklogs in Vercel
To manually enroll:
SELECT enroll_user('user-uuid', 'product-uuid');→ Reward only fires after the referee's first payment is confirmed.
Check referral_redemptions table:
SELECT * FROM referral_redemptions WHERE redeemed_by_tenant_id = 'tenant-uuid';
-- referrer_rewarded should flip to true after first payment→ Vercel Cron requires a Pro plan. Check Vercel dashboard → Cron Jobs tab. You can also trigger it manually:
curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
https://yourdomain.com/api/cron/expire-subscriptionsnpm install # sync dependencies
npm run build # check for TypeScript errorsCommon fixes: missing env var, or a DB migration not applied yet (supabase db push).
| URL | Purpose |
|---|---|
/en/platform |
Your super admin control room |
/en/platform/billing |
Confirm manual bank transfers |
/en/platform/tenants |
Manage all schools |
/en/create-school |
Where new schools sign up |
school.yourdomain.com/en/dashboard/admin |
School admin panel |
school.yourdomain.com/en/dashboard/student |
Student learning portal |
| Table | What's in it |
|---|---|
super_admins |
Your user ID |
tenants |
Every school: plan, billing status, Stripe customer ID |
tenant_users |
Who belongs to which school, with what role |
platform_plans |
Your pricing tiers |
platform_subscriptions |
One per school, active Stripe subscription |
platform_payment_requests |
Manual bank transfer requests |
referral_codes |
Referral codes (tenant or platform level) |
referral_redemptions |
Who used which code, reward status |
enrollments |
Student ↔ product access records |
transactions |
Every payment attempt |
| Variable | Why it matters |
|---|---|
SUPABASE_SERVICE_ROLE_KEY |
Bypasses RLS — never expose publicly |
STRIPE_WEBHOOK_SECRET |
Validates Connect webhooks (student payments) |
STRIPE_PLATFORM_WEBHOOK_SECRET |
Validates Billing webhooks (school payments) |
CRON_SECRET |
Secures subscription expiry endpoint |
NEXT_PUBLIC_PLATFORM_DOMAIN |
Controls subdomain routing for all tenants |
Last updated: February 2026