Samuel Onyebuchi-Igbokwe

Work AXE LMS

Learning platform · three roles, one codebase

Caught a grading queue overstating staff workload 9×.

AXE is the web platform behind AIENAI's two-week AI and design bootcamp, taking learners aged 13–19 from zero to a finished app prototype, a live pitch and a verifiable certificate. It serves three roles, Student, Tutor and Org, each a distinct experience from a single Next.js app. I lead development and wrote 280 of its 300 commits across 605 files.

The AXE learning management system dashboard.
Role
Lead Developer & UX Designer
Organisation
AIENAI (AXE Academy)
Period
2025–present
Status
In active development

Built with

  • Next.js 16
  • React 19
  • TypeScript
  • Tailwind v4
  • shadcn/ui on Base UI
  • Zustand
  • Supabase
  • Anthropic SDK
  • Vercel

281 → 31

Grading queue de-duplicated before staff triaged against it, a 9× overstatement

1.8s → 30ms

Sidebar selection feedback, by taking a client-only URL param off the server round-trip

2.3s → 0.6s

Catalogue time to content, collapsing an N+1 read from 3N+2 queries to 4

135

Row-level security policies enforcing tenant isolation

What the platform is

AXE LMS is the web platform for AIENAI's AXE AI and Design Bootcamp, a structured two-week programme that takes learners aged 13 to 19 from zero to a finished app prototype, a live pitch, and a verifiable digital certificate.

It serves three roles, Student, Tutor and Org, each with its own interface and feature set, from a single Next.js app with role-based routing. As lead developer I own ticket triage and prioritisation across the build, run a per-screen feature-branch workflow so each screen stays isolated and reviewable, and lead the platform walk-throughs before client demos.

The AXE learner dashboard showing weekly content, progress and assignments.
The learner dashboard. Tutors see a structurally different application from the same codebase.

Three roles, one codebase

Student, Tutor and Org are not three permission levels on one screen. They are three different products, routed by role out of a single Next.js app, and the Org layer is what lets the same build serve more than one academy.

Student

  • Guided onboarding and weekly content flow
  • Three-step assignment submission with auto-save
  • Instructor feedback by push and email
  • Practice Mode and presenter checklist
  • Progress tracking and self-assessment
  • QR-verified certificates and portfolio builder

Tutor

  • Cohort and student management dashboard
  • Content and curriculum management
  • Assignment review and structured feedback
  • Session management and attendance
  • At-risk student flagging
  • Ratings dashboard and certificate issuance

Org

  • Academy setup, branding and theming
  • Its own subdomain, <slug>.axe.ac
  • Membership and tutor access control
  • Cohorts and curriculum across the academy
  • Certificate issuance under the academy's brand

What I measured

Three fixes with a before and after. The first is computed from production data; the other two were measured the same way, minutes apart, so the comparison is like for like.

A grading queue that overstated its own workload 9×

The staff grading queue counted submission rows. One quiz sitting writes 6.7 of them, so the queue read 281 items where 31 pieces of work existed. For the largest academy it was 250 against 20, a 12.5× overstatement. Staff would have triaged against that number. The bug surfaced as 40 identical feed lines for a single quiz sitting, and the fix was to fold the rows in the read layer rather than count them.

Sidebar selection: 1.8s to 30ms

Course sidebar tabs ran through router.replace, and the page is an async server component, so the highlight could not move until the server render committed. That was roughly 1.8 seconds of dead-looking interface per click, plus a full server re-render for a parameter the page never reads. Routing client-only params through history.replaceState with an optimistic overlay brought the highlight to 28 to 38 milliseconds, with zero server renders in the request log.

Catalogue: an N+1 collapsed from 3N+2 queries to 4

getMyCoursesProgress issued three queries per course plus two. Collapsing it to four total took application time from 2.3s to 1.44s, and time to content on client navigation to 599ms, or 24ms cached.

Method: both latency figures were captured the same way, minutes apart, against the remote database, so the delta is like for like.

Five decisions worth showing

Each of these is a place where the obvious implementation would have worked and cost something later.

1. Lesson Runner: a redesign with no data migration

The authoring schema and the playback schema are different shapes. Tutors author a tri-split of slides, mini-tasks per slide, and a final quiz. Learners read a flat ordered list of beats: story, check, reflect.

deriveLessonBeats() is a pure function that interleaves the two models and emits stable ids such as story-${slideId}, so React keys survive re-render and deep linking works. startSlideId resolves to a beat index by id lookup, falling back to 0 rather than throwing.

I did not migrate the database to fit the new UI. I wrote a derivation layer, so the redesign shipped without a migration and the authoring shape was left free to catch up later.
A lesson in progress on AXE, showing beat dots, the status bar and an XP counter.
A check beat mid-lesson. Beats are derived at read time, not stored.

2. Lesson Builder: drag and drop without a drag-and-drop library

Reordering happens at three nesting levels: blocks within a slide, mini-tasks within a slide, and slides within a lesson. All of it runs on the native HTML5 drag-and-drop API with a shared generic reorder<T>(list, from, to), so no DnD library enters the bundle.

A single dragRef discriminated union, {kind: 'block' | 'task' | 'slide', fromIndex}, means a block can never be dropped into the slide rail. The type system enforces the drop target rather than a runtime guard. Persistence is debounced at 700ms into a server action, with an in-session history stack driving undo and redo behind a visible history panel.

The three-column AXE lesson builder with a slide card being dragged out of the right-hand rail.
Mid-drag. Three nesting levels of reordering, no drag-and-drop dependency.

3. AI course import that cannot corrupt a course

The model is constrained by a hand-written JSON Schema covering course tagline, overview sections, lessons, slides and mini-tasks with isCorrect flags, passed as structured output so the response is always parseable.

Generated lessons land as drafts. The /author endpoint does not touch the database at all: it returns a proposal, and insertion goes back through the builder's existing auth-gated persist actions. The model proposes; the tutor commits.

The AXE author assistant showing generated slides in a preview panel with an Insert button.
Generated slides sit in the panel until a tutor inserts them. Nothing is written on the model's say-so.

4. Tenant branding: one codebase, many academies

Three layers, each with a different job.

  • proxy.ts parses <slug>.axe.ac off the Host header and stamps an active_org cookie.
  • getCurrentTenant() treats that cookie as a hint only. It re-verifies membership against organisation_members on every request, so a spoofed slug resolves to nothing.
  • TenantThemeProvider writes the brand colours onto documentElement as CSS custom properties, with cleanup on unmount so a workspace switch never leaks the previous tenant's palette.
The organisation setup design step in AXE, showing a brand colour picker.
The design step of organisation setup. The same dashboard renders under any tenant's palette.

5. Grading queue: auto-marked and human-marked in one view

GradingQuestion is a discriminated union over auto-choice | text | file. Auto-choice arrives with pointsEarned already computed; text and file arrive with only pointsMax. The UI derives the tutor's remaining workload from the type rather than from a flag someone has to remember to set.

Reflection responses carry origin: 'reflection' and are filtered out of the queue entirely. They are learner-private, not assessed work.

The AXE grading queue showing a submission with auto-scored multiple choice and an ungraded free-text answer.
One submission, two kinds of marking. The type tells the interface which is which.

Where this stands

AXE is in active development across 605 files and 112,631 lines, with 135 row-level security policies doing the tenant isolation. The delivery discipline is the part I would defend hardest: per-screen branches, owned triage, and walk-throughs before every demo, so the platform stays demo-ready at short notice rather than being made presentable in a panic the night before.