Course Kit

Zero to 370+ paying users, alone.

Course Kit otter mascot peeking around the headline

A coach sells lessons in blocks of sessions. What happens next is everything I designed, built — and got wrong once.

Course Kit statistics overview
Course Kit self courses list
Course Kit collaborative course detail
Remaining
sessions
Invested
EUR
Unit price

Screens and figures are sample data

The problem

A freelance coach sells lessons in blocks. What is left of a block lives on a paper card, in a chat thread, or in a spreadsheet only the coach can open. The client has no way to check the same number.

✗ Before (Paper & Chats)

Cards get lost in wash · Spreadsheets out of sync · Awkward session-end disputes.

✓ After (Course Kit)

One shared record on both devices · Coach deducts, student verifies immediately · Zero friction.

A coach and a client beside a funnel of paper notes and spreadsheets pouring into one tablet

What I built

One record, open from both ends. The coach deducts a session; the client sees the same balance and the same history a moment later. Neither side keeps a private tally, so there is nothing left to disagree about.

Around that record: a Flutter app on iOS and Android, a web storefront, three payment rails, and a Firebase backend on GCP. All of it mine.

Course Kit course list with remaining sessions
Course list
Course Kit invite code screen
Invite code
Course Kit manager and member sync screen
Manager and member sync
Course Kit scheduling calendar
Scheduling
Course Kit self-recorded lesson log
Self recorded logs

Decisions

Tech Stack

Flutter & Firebase for 1-person scale

Two native apps plus a custom backend is three full-time jobs, and there was only one of me. So I cheated smart: one Flutter codebase for both app stores, and managed GCP services that keep the lights on while I sleep.

Business Model

One-time purchase over subscription

Avoided recurring dunning, billing state machines, and churn complexity upfront to validate willingness-to-pay on release one.

Data Isolation

Local-first vs cloud sync boundary

Self-recorded lessons live offline on phone (zero cloud cost); collaborative courses sync via Firestore accounts.

The interesting engineering

Selling on three platforms means being paid in three ways. Apple, Google and Stripe report a purchase in three different shapes. Cloud Functions normalise them into one Firestore entitlement schema, so the client never learns where the money came from.

Conflicts resolve to the highest tier, not the last write.

Every interface change was additive, so the three-tier pricing change shipped with zero regressions.

Apple IAP iOS app Google Play Android app Stripe Web store Cloud Functions normalise Firestore entitlement
Every purchase lands in the same schema, whichever rail it came through.

Results

13releases
8months
3platforms
370+users
138actives
0ad spend

A growing base of paying users acquired purely through organic SEO with zero paid advertising.

Architectural Evolution & Post-Mortem

Here is where I got it wrong the first time: I drew my data boundaries by feature, not by plan. Then self-recording users asked for multi-device sync, and that one decision cost me a 2.5-week migration. An expensive lesson — but you only need to learn it once.

Architecture Version Data Boundary Design Migration Friction Key Engineering Lesson
V1: Feature Boundary Offline SQLite for self-records; Cloud Firestore for collaborative courses. 2.5 Weeks Migration to move local records to cloud when adding sync. Drawing boundaries on features causes painful schema forks across tiers.
V2: Plan Boundary (Refactored) Unified account model with entitlement flags controlling cloud sync vs local storage. Zero Migration Cost when users upgrade plans. Architecture should scale by user entitlement tiers, not feature branches.

Stack

FlutterFirebase Cloud FunctionsFirestoreGCPStripeApple IAPGoogle Play IAPResendClaude