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
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