Published 2026-02-20
An English Site With an LMS Platform: What Building Courses From Scratch Takes
Next.js, Clerk, Stripe, and a custom player — plus a content-protection trade-off I made deliberately.
For the new English website it wasn’t enough to just translate the texts from Czech. The goal wasn’t a copy of the original site, but a standalone platform: a public website, a blog, a course catalog, sign-in, payments, a members’ section, a lesson player, and, on top of that, the preparation of English videos.
In this article I focus mainly on the technical implementation.
The application foundation
The site is built on Next.js 15 with the App Router and React 19. I split the structure into two main parts.
The public part contains the landing page, blog, courses, contact, and legal pages. The members’ section holds purchased courses, lesson playback, and account settings.
Sign-in runs through Clerk. Middleware guards all pages under /dashboard/*. When an unauthenticated user reaches them, it sends them to sign-in and remembers the original address so that after signing in they return to where they were headed.
Courses, payments, and access
The data is in PostgreSQL via Drizzle ORM. The schema covers courses, modules, lessons, reviews, customers, purchases, and lesson-watching progress. Supabase serves as the database environment.
I connected payments through Stripe Checkout. At purchase, information about the course and the customer is stored with the Stripe session. After payment, a webhook arrives and creates access to the course.
Here a small but important problem arises. The user returns from Stripe to the site within a second or two, and the webhook may arrive later. If the page decided right away, a customer who had already paid would see “you don’t have access.”
I handled this on the player side: after returning from payment, the page briefly retries checking access, and only then decides. The source of truth stays the webhook, not the return URL — access isn’t granted from URL parameters, because the user controls those. If the webhook never arrives at all, the check eventually times out and the customer is told that payment is still processing, instead of being told they never bought anything.
Video player
The video part is built on the YouTube IFrame API and youtube-nocookie.com. But it wasn’t just about embedding an iframe. The custom player handles:
- controls,
- resuming from the last position,
- playback speed,
- fullscreen,
- keyboard shortcuts,
- online/offline state,
- saving progress roughly every 10 seconds.
Once I needed to track progress on videos, switch lessons, and serve content only to signed-in students, a plain embed code turned into an important part of the application.
It’s worth mentioning what this choice means for content protection. The YouTube iframe has the video ID in the page’s code, so the paid content isn’t protected by a paywall, but by the video being unlisted. Anyone who pulls out the ID can pass the link along.
It’s a deliberate trade-off. Hosting with signed URLs and DRM, for example through Mux or Cloudflare Stream, solves exactly this, but at this course’s volume it would cost more than the expected loss from any sharing. The player is also an isolated component with a stable interface, so swapping the video source is a local change, not a rewrite of the members’ section. When the ratio flips, it’s ready.
A blog without an external editorial system
The blog is built on files. Articles are markdown files with metadata in the header. The server loads them, extracts the title, date, and other fields, renders the content via react-markdown, and builds search with Fuse.js.
This solution is simple and, for this type of site, practical. There’s no need for an external CMS, the content is under version control, and it deploys together with the application.
English as the primary language, not a toggle
This wasn’t a classic multilingual solution with a toggle. The English version is the application’s primary language: html lang="en", Clerk in English, Stripe in English, and English text in the interface.
For the blog it was mainly a content migration. The Czech article is saved as a backup, the English version goes in its place, and the titles, slugs, categories, tags, and internal links are translated. Then the search index is rebuilt.
The hardest part wasn’t the website, but the videos
The most labor-intensive part was preparing the English videos. Dubbing isn’t just translating the text. I had to handle terminology, timing, voice quality, and continuity with the movement in the video.
The English site, in the end, isn’t just a translated page. It’s a product of its own, with content, payments, a course, a player, and operational details that have to hold together.