Jay Stewart
In production2026 — presentArchitecture, implementation, infrastructure, operations

SpiralClass

Scheduling, payments and live video teaching for independent language teachers, live in production since May 2026.

  • Next.js
  • React Native
  • Postgres
  • Stripe
  • LiveKit
  • Terraform
  • Fly.io
Lines of code
427,121
API routes
288
Test files
947
Decision records
131

Counted from the source repository by scripts/count-metrics.mjs, not estimated.

Contents
  1. 01Constraints that shaped it
  2. 02Architecture
  3. 03Payments, and the rail that had to exist
  4. 04Infrastructure
  5. 05Where the hard problems actually were
  6. 06The design system, and what measuring it found
  7. 07What it cost
  8. 08Where it stands

An independent language teacher runs a business with none of the tooling a school would hand them. Booking happens in WhatsApp. Payment happens by transfer, or in cash, or not at all until someone remembers to ask. Reminders are a message typed by hand the night before. Class materials live in a folder nobody can find, and the video call is whatever link was free that week.

Every one of those is a solved problem in isolation. The difficulty is that a teacher does not have a booking problem or a payments problem — they have one business, and the tools do not know about each other. A booking that does not know whether it was paid for is not much better than a note in a diary.

SpiralClass is the attempt to make those parts one product: a public booking page per teacher, package purchases and subscriptions, payouts that cross a border, live classes with AI-translated captions, homework and materials, and an admin console to run it.

It has been live since May 2026, carrying real bookings and real money.

Constraints that shaped it

Four constraints did more to determine the architecture than any preference of mine.

The launch market was Mexico, and Stripe Connect does not serve it. Payouts are the entire value proposition for a teacher — a booking system that cannot pay them is a calendar. This one fact forced a second payout rail before launch rather than after. The two-rail design is also what keeps the product market-agnostic rather than market-bound: Stripe Connect pays teachers in the UK, US, EEA, Canada and Switzerland, and the manual rail covers everywhere it does not — so the system is built for international use, and the launch market happened to be the one place the easy rail could not reach.

Teachers are on Android, students are on anything. The mobile client is Android-only, which is a scope decision rather than an oversight. Building for one platform properly beat building for two badly, and the split matches who actually needs an app rather than a browser.

Two languages, asymmetrically. Teachers work in Spanish. Students are learning English, so student-facing copy is in English on purpose — the product is part of the lesson. That asymmetry is a product decision that leaks straight into the i18n design.

One engineer, indefinitely. Not a temporary state to be corrected by hiring. Every choice had to be evaluated against whether one person could operate it at 3am, which ruled out a great deal of otherwise reasonable architecture.

Architecture

Two clients, one API, six backing services.

Web app

Next.js · Server Components

spiralclass.com

Android app

Expo · React Native

Google Play, OTA updates

API layer

Route handlers + Server Actions

Postgres

Neon · Prisma

82 models

Background jobs

Inngest

31 functions

Live video

Self-hosted LiveKit

+ captions worker

Object storage

Cloudflare R2

Email & push

SES · Expo

Payments

Stripe · Wise

Both clients talk to the same route handlers. There is no separate mobile backend and no BFF layer — the mobile surface is a namespace within the same API, sharing the same validated wire types. A web app and an Android app both call a shared API layer, which talks to Postgres, a background job runner, a self-hosted video server, object storage, an email and push service, and payment providers.

The web app is Next.js on the App Router — Server Components by default, client islands only where interaction demands them, mutations through Server Actions. There is no tRPC or GraphQL layer and no client-side global store. That is not minimalism for its own sake: it removes an entire category of state-synchronisation bug, because for most of this product the server is the state, and a second copy of it in the browser is a second thing that can be wrong.

The Android app mirrors the web app’s route groups — teacher dashboard, student portal, public booking, admin — so both clients share one mental model of the product. When a route moves on the web, the corresponding mobile screen is obvious.

Screenshot pending

The public booking page a teacher shares. It is the only page most students ever see, and the only one that has to work for someone who has never used the product.

Payments, and the rail that had to exist

Payments touch three different Stripe products: Checkout for one-off package purchases, Connect Express for teacher payouts, and Billing for the platform’s own subscriptions. Collapsing them into one abstraction was tempting and would have been a mistake — they have different failure modes, different webhooks and different reconciliation stories. Hiding that behind a shared interface would have hidden the bugs, not prevented them.

Student pays

Stripe Checkout

Packages, single classes

Teacher subscribes

Stripe Billing

Free · Pro · Founding

Ledger

Integer minor units

Never a float

Stripe Connect

Supported countries

Automated transfer

Wise

Everywhere else

Operator-confirmed

Three Stripe flows, and a deliberately separate manual rail for markets Stripe Connect does not reach — including the launch market. Student payments flow through Stripe Checkout into a ledger. Teacher payouts go through either Stripe Connect for supported countries or a manual Wise rail for unsupported ones. Platform subscriptions run through Stripe Billing.

Three details in there matter more than the shape:

Money is stored as integers, in minor units, everywhere. No floats, ever. This is old advice and it is still correct, and the discipline has to be total — one parseFloat at a boundary reintroduces the whole class of bug.

Payouts read back the settled net before transferring. Not the amount the invoice said, not the amount charged — the actual figure from Stripe’s balance transactions after fees. Computing the fee locally means maintaining a model of someone else’s pricing, and being quietly wrong about it every time it changes.

The Wise rail is separate, not unified. It would have been cleaner to force both payout paths behind one interface. But one is automated and idempotent and one requires a human to confirm a transfer happened; pretending they are the same operation would mean the abstraction lies about the most important property either has.

Infrastructure

Every server is provisioned as code. Nothing was clicked into existence.

Terraform

OpenTofu

One source of truth

App

Fly.io

Two machines, production

Postgres

Neon

Branch per preview

Storage & docs

Cloudflare

R2 + Workers

Video server

Oracle free tier

LiveKit

Providers chosen per workload rather than per vendor relationship. The video server runs on a free tier because a media server has different economics from an app server. Terraform provisions application hosting on Fly.io, Postgres on Neon, object storage and a documentation site on Cloudflare, and a self-hosted video server on Oracle Cloud free tier.

Secrets are managed with separate scopes for preview, production and the infrastructure layer itself. That last separation is the one worth naming: the credentials that provision infrastructure never sync into the running application. A compromised app runtime can read the database; it cannot reach the account that could delete it.

The release gate

A release is a gate, not a git push.

Merging to main deploys a preview environment with its own disposable database branch. Production is a separate branch that only advances by fast-forward, and only after static analysis, unit tests, a real-Postgres integration suite, a mobile native-code guard, mobile smoke tests and a full end-to-end run have passed against the promote candidate.

The end-to-end suite is deliberately not in the per-PR checks. It exercises the whole booking-to-payment path against a production build and it is slow; running it on every commit would make day-to-day merges painful enough that someone would start skipping it. Running it once, at the point where it actually gates something, keeps it credible.

Database rollback gets its own mechanism, because a code rollback does not undo a migration. Reverting the deploy leaves the schema where the migration put it, and the previous release meets a database it does not recognise. So rollback is a database branch checkpoint taken before promotion, restored independently of the application.

Where the hard problems actually were

Live video, and owning the media server

Classes run over a self-hosted LiveKit server rather than a managed video API. That is more operational work, and it bought one specific capability: a separate worker can join a room as a participant, stream each speaker’s audio to a transcription service, translate it, and republish captions into the call live — gated per speaker by explicit consent.

That is not possible as a customer of a video API. It is possible when you own the media server, and it is the feature the product is actually differentiated by. The worker runs as its own deployable service on its own box, not bundled into the web app, because a captions pipeline crashing should not take down booking.

Sessions that can be revoked

Sessions are database-backed rather than stateless JWTs. The usual argument for JWTs is avoiding a database round trip per request; the usual cost is that you cannot revoke one before it expires.

For a product holding payment details and running live video into people’s homes, being able to inspect and kill a session server-side was worth the round trip. The admin console has its own capability-based role ladder — superadmin, finance, support, tester, engineer — enforced at the route layer, not by hiding buttons in the UI.

Mobile updates that cannot silently break

Over-the-air updates are the reason to use Expo and the fastest way to break an app in the field. An OTA bundle assumes the native code it was built against; ship a JavaScript update that calls a native module the installed binary does not have, and it crashes on launch with no path to recover.

So CI fails the build if native code changed without a matching runtime-version bump, and the promote step diffs the candidate against what is actually live rather than against the previous commit. The check exists because the failure is invisible until it reaches real devices.

Internationalisation as a ratchet

The i18n catalogue is hand-rolled rather than a framework, because the asymmetry between teacher-Spanish and student-English does not fit the “one locale per user” model most libraries assume.

The interesting part is not the catalogue, it is the enforcement. A lint rule plus a ratchet test fail CI on new hardcoded strings while tolerating the existing ones. The migration is genuinely unfinished; the ratchet means it can only get better, and nobody has to do a big-bang rewrite to stop the bleeding.

Monitoring that costs what it is worth

Errors and product analytics are deliberately separate tools, and session replay only captures on error — full-session capture ate most of a free-tier quota during testing alone.

Production is also probed directly every twelve hours: the homepage, the health check, a public booking page, video server reachability, and a forged-signature request against the payment webhook to confirm it is still rejecting them. That last one is the only check in the set that would catch a signature-verification regression, which is exactly the kind of change that looks harmless in review.

The design system, and what measuring it found

The product had a design system on paper: one canonical palette in a shared package, mirrored to CSS, guarded by a test that converted the CSS back to hex and failed on drift. That machinery was real and it worked.

What it did not have was any check on whether the colours could do their jobs. Scoring every pairing the tokens asserted was legible turned up four roles the system claimed and could not perform. The gold documented as “the brand’s second colour, for highlights and secondary emphasis” sat at 2.16:1 and could not carry text anywhere. Dark-mode error text failed at 4.19:1 — the one colour that most needs to be readable. Control borders sat at 1.39:1 in a product that is roughly ninety forms.

One cause behind all four: the palette was authored as appearance and then assigned roles. Contrast was something to check afterwards, and nobody did.

The consumption layer leaked worse than the definition layer. A scanner counting bypasses found 453 of them across 104 files. The 119 raw Tailwind palette utilities were not an aesthetic problem — they carry no dark: counterpart, so every one rendered light-on-light in dark mode. Most were hand-rolled pills duplicating Badge and Alert variants that already existed and were unused.

The direction

Legibility became the governing constraint rather than a checklist item at the end. The strongest single mechanism is not a typeface: dyslexia does not present one way, so the reader sets text size, letter and word spacing and background tint, and the choice persists on their account rather than in one browser.

A dyslexia-branded font was rejected. Controlled studies have repeatedly failed to show a reading-speed or accuracy advantage for OpenDyslexic or Dyslexie over an ordinary well-drawn sans-serif; shipping one would have been the legible-looking answer rather than the legible one. Atkinson Hyperlegible instead, whose mechanism does transfer — commonly confused characters are drawn as genuinely different shapes rather than mirrored ones.

Colours are now derived rather than chosen: a search over OKLCH lightness for the smallest step that clears the ratio each role requires, solved against the hardest surface in its own theme. Body text targets about 13:1 — past AAA, and deliberately short of black-on-white’s 21:1, which is the pairing most often reported as causing glare.

What made it stick

The direction is the easy half. Four mechanisms keep it:

  • A drift ratchet counting seven kinds of bypass per file. A file may not add any beyond its baseline, and a file migrated to zero is protected.
  • A lint rule that explains the fix where someone types it — errors on the primitives, warnings on surfaces still being converted, because a rule that fires 344 times on day one is a rule people disable.
  • A contrast test over every pairing in both themes, which asserts the opposite direction too: body text must stay under 16:1.
  • A visual baseline of 1,164 captures across 98 routes at six viewports in both themes, taken before anything moved.

Each caught something the others missed. The contrast test failed on its first run, on a colour introduced while writing the replacement palette. The accessibility sweep, extended to the signed-in product, found that the badge components used a colour the contrast test was not checking — the tinted grounds existed in the shared palette and had never been mirrored into CSS, so no utility could reach them and every variant reached for an opacity instead.

That turned out to be the recurring shape: three separate defects were all one root cause, a token defined in the shared package but never wired through to something a class name could reach. The type scale had the same gap, which is why 47 arbitrary font sizes had grown underneath it — including 10px and 9px text that no part of the system ever sanctioned.

The measurement that was wrong

Wiring the type scale in dropped those 47 arbitrary sizes to zero, and I reported it as a win. It was not one.

The scale’s smallest step was 11px. The migration moved the 47 sizes — 10px and 9px among them — onto that step. The drift count went to zero and not one of those 47 pieces of text got larger. An unnamed violation had become a named one, which is worse, because the name made it look decided. The written rule said body text is never under 17px and supporting text never under 15px.

It was also the wrong half of the problem. Wiring the scale in added a second scale beside the framework’s own rather than replacing it, and the app went on using the framework’s: one size class appeared 802 times and another 499, at 14px and 12px. The floor was true only of the steps almost nothing used.

Three guards were watching and none of them fired. The type-scale test asserted that every step was at least as large as the smallest step — a tautology, so it certified the exact violation it existed to catch. The drift scanner counts arbitrary values, so a named token at the wrong size is invisible to it by construction. And the parity test compares the palette against its CSS mirror, which on the same day was found agreeing with itself about an inverted dark surface ladder: both sides encoded the card as dimmer than the button behind it.

The fix deleted the 11px step rather than raising it, moved body to 17px, and redefined the framework’s own size keys onto the same floors — redefined rather than removed, because removing them would have made 1,300 class names silently emit nothing, and the framework does not error on a class it cannot resolve.

The general lesson is the useful part, and it took three instances in one day to see it: a guard that derives its bound from the thing it is guarding proves only that the thing equals itself. Each of the three had to be re-pointed at an external constant — the literal 17 and 15, the WCAG ratio, the luminance ordering. A test can be green, specific, and well-commented while checking nothing at all.

Raw palette utilities went from 119 to zero, ad-hoc scrim opacities from 51 to zero, and total bypasses from 453 across 104 files to 207 across 73. The remainder is a long tail of genuinely contextual values, and the raw <button> elements were deliberately left alone: most are drag handles and video-stage controls where the shared primitive would change what the element is, and none of them removes its focus ring.

What it cost

The honest version is more useful than the brochure version.

The stack was migrated once, mid-flight. The product started on a different hosting and database provider and moved to the current one. The old infrastructure code was deleted rather than left in place “in case” — but the migration consumed weeks, and it happened because the original choice was made before the workload was understood.

Some of the documentation rotted. An audit found dozens of source comments still reasoning from the decommissioned infrastructure, and four false claims in the instruction files the coding agents read before every change. What that cost, and what got built in response, is its own case study.

The test suite is uneven. Coverage is enforced by a ratchet on changed lines rather than a global threshold, which is a pragmatic choice that also means older modules are less covered than newer ones, and the number does not tell you which is which.

Android only. iOS teachers cannot use the mobile app. That is a real product gap being carried deliberately.

Where it stands

Live since May 2026, deliberately as a single-teacher alpha rather than an open launch — real bookings, real payments, a real person whose income depends on it working, and a scope small enough that a bug is a phone call rather than an incident.

A pre-launch audit set a conditional go and flagged payment edge cases that needed closing before general availability. I have been closing those against real usage since.

It is further from finished than a portfolio piece usually admits, and further along than most side projects reach, because it is carrying real money and real people’s schedules today.