ENFRKOantony langlois
work

XiLearn

visitwebsiteNext.jsNotion APIStripeMoodle

Trilingual e-learning platform for Laos · a Next.js storefront with Notion as its only database sells the courses · a self-hosted Moodle teaches them · Stripe checkout in en / fr / lo

Architecture

Next.js client
Frontend · storefront
Storefront UI
src/app/[locale] · app router
Catalog, course pages, checkout · renders Stripe PaymentElement
SaaS
External · payments
Stripe
Payment Intents · card confirm in the browser
Node
Core · Next.js server
App Router server
src/app · SSR + API routes
Reads Notion per render · /api/create-payment-intent · /api/addStudent
next-intl locale middlewareserver data to client contextsidempotent signup writeshreflang + sitemap SEOsecret keys server-side only
php · apache
Service · LMS
Moodle LMS
xilearn-moodle · Docker
Self-hosted · cron heartbeat every minute baked in
SaaS
External · CMS + back office
Notion databases
Courses · categories · student queue (Need to be added)
localhost:8081
Tooling · DB admin
phpMyAdmin
Dev-only inspection of Moodle tables
Docker volume
Database · LMS data
MariaDB
mdl_* tables · files in moodledata volume

A visitor lands on the Storefront UI at /lo and the App Router server answers by pulling the trilingual catalog from Notion and rendering it server-side. At checkout the server mints a PaymentIntent through /api/create-payment-intent, the browser confirms the card with Stripe's Payment Element, and /api/addStudent writes the learner into the Notion student queue with status Need to be added, deduped on email + course. Ops works that queue and enrolls the learner by hand into Moodle, which runs as its own Docker stack and persists everything to MariaDB · phpMyAdmin rides along for dev-time inspection.

Deployed: Storefront → Next.js · xilearn.com · LMS stack → Docker Compose · Moodle + MariaDB + phpMyAdmin

Outcomes

  • 3 locales · en / fr / lo · Lao script gets its own font
  • 0 admin UI · Notion is the CMS, back office and enrollment queue
  • 2 systems · storefront and LMS deploy independently
  • 1 command · full LMS stack up via docker compose

Skills demonstrated: headless-CMS data modeling · Stripe Payment Intents lifecycle · locale-prefixed SEO routing · packaging a PHP monolith in Docker · idempotent write guards

Problems and solutions

Every choice answers one constraint: a tiny team selling trilingual courses cannot afford to build or babysit custom backend systems.

▩ Buy an LMS, build the storefront

Problem: A real course platform needs lessons, quizzes, progress tracking and certificates. Building that alone takes years, but off-the-shelf Moodle looks dated and cannot do marketing, SEO or card checkout.

Split the product in two: a Next.js storefront owns discovery, languages and payments, while a self-hosted Moodle owns all teaching. They meet only at enrollment.

  • Rejected building a custom LMS: quizzes and progress tracking are solved problems, not differentiators
  • Rejected theming Moodle into a marketing site: fighting a PHP monolith for every pixel
  • Accepted trade-off: two deployments and a seam that needs bridging

▦ Notion as CMS, database and back office

Problem: Course copy exists in English, French and Lao and is edited by non-developers. A custom admin panel, or a headless-CMS subscription, is a whole project before the first course sells.

Notion databases are the only datastore the storefront has. Courses, categories and student signups live in tables the team already edits daily; the server reads them through the official API on every render.

  • Beat Contentful-style SaaS: zero new tooling for editors, zero extra cost
  • One row holds all three languages side by side, so translations never drift apart
  • Accepted weak querying and API latency; the catalog size makes both irrelevant

▧ A human enrollment queue instead of a Moodle API bridge

Problem: After payment a learner needs a Moodle account and a course enrollment. Moodle web-service automation is brittle to set up, and early on every payment deserves a human look.

Checkout writes the learner to a Notion table with status Need to be added; ops works the queue and enrolls by hand. A dedupe filter on email + course + status keeps retries from creating double rows.

  • Rejected auto-provisioning via Moodle web services: days of integration for a step a human does in seconds at current volume
  • Manual review doubles as payment verification
  • Accepted trade-off: access is not instant, which cohort-style courses tolerate

▥ Embedded Stripe checkout, not a hosted redirect

Problem: Sending buyers to an external payment page breaks trust on a site serving a market where paying by card online is already a leap.

The server mints a PaymentIntent per checkout and the browser renders Stripe's Payment Element inline, confirming with a redirect to /payment-success. The secret key never leaves the server.

  • Rejected Stripe hosted Checkout: an off-brand redirect at the most sensitive moment
  • A QR-code payment path was prototyped, then shelved until a local provider is worth the upkeep
  • Automatic payment methods let Stripe decide what to offer each buyer

▨ Trilingual routing that search engines can index

Problem: Lao script renders poorly in Latin fonts, and client-side translation hides the Lao and French catalog from search engines.

next-intl middleware serves locale-prefixed URLs (/en, /fr, /lo) with hreflang alternates, and the layout swaps in Noto Sans Lao when the locale is lo. UI strings live in JSON, content translations in Notion columns.

  • Rejected client-side i18n libraries: no indexable per-language URLs
  • Per-locale font loading keeps Lao text readable without shipping every font to everyone
  • Accepted cost: every content field exists three times in the CMS

▣ Moodle from source in a custom Docker image

Problem: Moodle needs exact PHP extensions, a writable data directory and a cron heartbeat. Hand-installed servers rot and cannot be rebuilt when they break.

A custom php-apache Dockerfile bakes in every extension, opcache and a per-minute cron; config.php and the Moodle source mount as volumes and the whole stack starts with one compose command.

  • Rejected the prebuilt Bitnami image: opaque layers, hard to pin the exact Moodle release
  • Source checkout pinned to a stable branch, so upgrades are deliberate
  • Accepted owning the image; in exchange dev and prod are byte-identical