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.
- 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.
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.
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.
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.
4. Tenant branding: one codebase, many academies
Three layers, each with a different job.
-
proxy.tsparses<slug>.axe.acoff the Host header and stamps anactive_orgcookie. -
getCurrentTenant()treats that cookie as a hint only. It re-verifies membership againstorganisation_memberson every request, so a spoofed slug resolves to nothing. -
TenantThemeProviderwrites the brand colours ontodocumentElementas CSS custom properties, with cleanup on unmount so a workspace switch never leaks the previous 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.
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.