Portfolio
  • 01Home
  • 02Projects
  • 03About
  • 04Skills
  • 05Lab
  • 06Writing
  • 07Contact

© 2026 Hamza Syrage

All Projects
July 18, 2026
reacttypescriptmulti-tenantsaaslms
Stack
ReactTypeScriptViteTanStack QueryJotaiReact Hook FormZodi18nextTailwind
Built with
Axenso×Lucidly
Live Demo

Cube26: Building the Learner App for a Multi-Tenant SaaS LMS

How Cube26's learner-facing SaaS app reskins itself per client at runtime, resumes a course from exactly where you left off, and tracks seven different content formats through one shared contract. Built for Axenso with Lucidly.

Cube26 is a SaaS learning management system built for companies that need to run onboarding, compliance, and internal training at scale, without spinning up a separate deployment for every client. One SaaS codebase, many organizations, each one living on its own subdomain with its own branding, its own learners, and its own admin dashboard.

Built for Axenso, with Lucidly leading SaaS product design and development. I was on the frontend team building the learner-facing side of the SaaS platform on React and TypeScript: the part every employee actually opens to sit through their training.

Cube26 SaaS Training Platform.
Cube26 SaaS Training Platform.
Scope of this post

This covers the learner-facing app, the side an employee actually uses to take a course. There's a separate admin dashboard for building and managing that content, built by another part of the team, and it's a whole project of its own.

#At a glance

  • Product type: multi-tenant SaaS learning management system
  • My role: frontend engineer on the learner-facing SaaS app, one platform serving every client tenant
  • Stack: React, TypeScript, Vite, TanStack Query, Jotai, React Hook Form + Zod, i18next, Tailwind
  • Scale: one SaaS codebase, unlimited organizations, each with its own branding, language, and course library
  • Content formats supported: video, audio, PDF, plain text, spreadsheets, zip archives, image galleries
  • Built for: Axenso, with Lucidly
  • Live at: cube26.io

#The Problem With "One SaaS App, Many Clients"

The hard part of a white-label SaaS product isn't building a nice course player. It's making sure the exact same JavaScript bundle can land on ten different subdomains and feel like ten different products, without a new build for any of them. Everything below is one answer or another to that same question: how do you keep a single SaaS codebase feeling custom, fast, and correct for every organization that signs on to it.

Category browsing with filter and sort controls.
Category browsing, themed and filterable per organization.

#Runtime Theming: Every Subdomain Gets Its Own Paint Job

Every organization gets its own colors, its own logo, and its own tag colors for things like "mandatory," "completed," and "expiring soon," all without a separate deploy. That rules out deciding the theme at build time. It has to be resolved in the browser, after the app has already booted, based on which subdomain it's running on.

Rendering diagram...

An administrator can change their brand color from their own dashboard and see it reflected across the entire SaaS platform on next load, no engineering ticket required. The Tailwind color tokens are wired to those same CSS variables, so individual components never need to know a theming system exists at all. A card just uses its normal color classes, and whichever tenant loaded it gets the right palette for free, the way any well-built multi-tenant SaaS product should behave.

Profile settings screen.
Profile settings screen.

#Resuming a Course Exactly Where You Left Off

Open a course you've already started and Cube26 doesn't drop you at the table of contents. It jumps straight to the next thing you haven't finished, whether that's three sections deep or a quiz sitting right at the end of a section you just wrapped up.

#A Course Is a Tree, Not a Playlist

A course is made of sections, sections can hold their own sub-sections, and both can hold lessons and a quiz. Progress unlocks in order, so a mandatory read always comes before the quiz that tests it.

Rendering diagram...

#Finding "Next" Without Getting It Subtly Wrong

The tricky part isn't finding an unfinished item, it's finding the right one, in the order a learner would actually experience it, while respecting anything still locked. The app walks the tree section by section, checking regular lessons first, then anything inside a sub-section, then that sub-section's quiz, then the parent section's own quiz, and stops at the very first thing that's both unlocked and incomplete.

Rendering diagram...

Once that target is found, the sidebar needs to auto-expand to show it, rather than leaving a learner to hunt through a collapsed accordion. A second pass walks back up the tree from that item to build the list of parent sections that need to be open on load.

None of this is hard computer science. It's the kind of logic that's genuinely easy to get subtly wrong, an off-by-one in the direction you walk the tree, or a quiz treated as "next" when its section is actually still locked. It's also tedious to test properly, since you need a course with a real mix of finished, locked, and in-progress content to catch the edge cases. Once it's right, though, it disappears. Nobody notices when "continue" lands them in the correct spot. They only notice when it doesn't.

Course player with sidebar section tree and pdf lesson open.
Course player with sidebar section tree and pdf lesson open.

#Seven Content Types, One Shared Tracking Contract

A course can mix video, audio, plain reading material, PDFs, spreadsheets, zip archives, and image galleries. Every format gets its own dedicated player, but every one of them reports progress through the same shared contract, so the surrounding shell, sidebar, header, and next/previous controls never needs to know or care what kind of content is currently on screen.

#What "Progress" Means Changes by Format

FormatHow progress is measured
VideoContinuous playback position, resumes from the exact second
AudioSame continuous position tracking, rendered against a real waveform
PDFMeasured engagement time while the tab is actually visible
Plain textSame engagement-time tracking as PDF
Zip archiveSame engagement-time tracking as PDF
Image galleryMarked complete the moment it's opened
SpreadsheetMarked complete the moment the download starts

That last row surprised me a little going in. It would be natural to assume a PDF tracks which page you're on, but it actually tracks how long you've genuinely had it open and focused, pausing the instant you switch tabs and picking back up when you return. It turned out to be the more honest signal anyway: a policy document someone actually read for three minutes says more than a page number someone could have arrived at instantly.

Cube26 course player on a mobile viewport.
Cube26 course player on a mobile viewport.

#Keeping Five Lists in Sync After Every Update

A single progress update, finishing a video, downloading a spreadsheet, can affect up to five different views a learner might currently have open: the full catalog, "my courses," favorites, the ongoing list, and the cache the home page pulls its cards from. None of those views automatically know about a change that happened somewhere else in the app.

Every successful tracking update walks all five, finds the affected course wherever it currently appears, and patches its progress in place. When a course crosses full completion, it also gets flagged complete everywhere and dropped out of the "ongoing" list entirely, since a finished course doesn't belong there anymore.

It works, and it's honestly one of the less elegant corners of the app. Patching five separate caches by hand in response to one event is fine at the current scale, but it's the kind of thing that would need a rethink, most likely one normalized store keyed by course ID that every view reads from, if the number of course lists on screen kept growing.

#One Search Bar, the Whole Platform

Search isn't scoped to a single page. Type into it from anywhere and it looks across the entire catalog at once, courses and categories together, each returned as its own paginated result set rather than one blended list.

Rendering diagram...

A new hire almost never knows the exact name of the course they need. They know the topic. Letting a single search answer both "what course covers this" and "what category does this belong to" is what makes the search bar an actual starting point, not a shortcut for people who already know what they're looking for, which matters more in a SaaS product where the catalog only ever grows.

Survey screen.
Survey screen.

#What Shipping This Actually Looked Like

Nothing described above is a hard algorithm on its own. The theming system is a fetch and a set of CSS variables. The resume logic is a tree walk. The tracking system is one shared contract reused seven times with a definition of "progress" that shifts depending on the format. What actually took the time was keeping all of it consistent across a growing SaaS platform, once fifteen or twenty pieces like this were live at once: a theme change on one tenant never leaking into another, a progress update from a video not going stale in four other lists, a locked quiz never showing up as "next" because of a boundary condition nobody tested yet.

That's the real shape of building a multi-tenant SaaS product. The interesting problems aren't in any one file. They're in making sure the boring parts stay correct everywhere, all the time, for every SaaS client added long after anyone's still thinking about the code that shipped first.

Contents

At a glance
The Problem With "One SaaS App, Many Clients"
Runtime Theming: Every Subdomain Gets Its Own Paint Job
Resuming a Course Exactly Where You Left Off
A Course Is a Tree, Not a Playlist
Finding "Next" Without Getting It Subtly Wrong
Seven Content Types, One Shared Tracking Contract
What "Progress" Means Changes by Format
Keeping Five Lists in Sync After Every Update
One Search Bar, the Whole Platform
What Shipping This Actually Looked Like