Skip to content
Huy Vo
Work

Skillify · E-learning · iOS, Android, web, CMS

Skillify

A multilingual learning app in both stores, its web player and its admin: video and audio courses, streaks, subscriptions, AI learning paths.

Visit skillify.asia (opens in a new tab)

Skillify, the real app on a phone

Skillify app screen: Reasons to come back

Real screens

The Expo app built for the web, signed in to my own account from a read-only snapshot.

Role
Lead mobile developer, web and CMS features
Year
2025–2026
Platforms
iOS · Android · Web · CMS
Stack
  • Expo SDK 58
  • React Native 0.88
  • TanStack Query
  • Reanimated
  • Next.js 16.4
  • RevenueCat

Skillify sells courses, books and audiobooks, and runs white-label learning portals for partners. I joined when the app was a scaffold and became its main developer.

My part. 70% of the app's commits; 35% of the web and 49% of the CMS. Media players, learning screens, streaks, subscriptions, push and analytics are mine.

Outcomes

  • −23%

    mobile LCP on the web (8.3 → 6.4 s)

    Source: Lighthouse lab

  • −11%

    app cold start on iPhone 11

    Source: QA report

  • −5.4 MB

    app download size

    Source: QA report

What I built

Media that behaves

A video player with subtitles, Vimeo, fullscreen and resume, and audio courses that play on into the next chapter with lock-screen controls.

Skillify app screen: Media that behaves

Reasons to come back

An AI learning-path onboarding, streaks with celebrate, restore and share, a diamond store, subscriptions through RevenueCat, leaderboards and a badge collection.

Skillify app screen: Reasons to come back

The web player

A web lesson player with custom subtitles, double-tap seek and a mobile unmute prompt; the web's account pages moved to server rendering on Next 16 with the React Compiler.

The website

The learner website, built from its source and signed in to my own account from a read-only snapshot.

Screen recording

Architecture

LEARNER APP · EXPOLEARNER WEB · NEXT.JSCMS · NEXT.JSOneSignalpushRevenueCatsubscriptionsFirebaseGA4 · CrashlyticsEAS UpdateOTA releasesPushpermission flowSubscriptionspaywallAnalyticstyped eventsVideo · audioplayers · resumeLearningquiz engineStreaksbadges · ranksScaffoldUI kit · teamSkillify APIshared backendLearner sitemulti-tenantLesson playersubtitles · seekAdmin CMScontent builderAI translation+ Drive importRESTRESTREST
Three clients share the team's Skillify API: the Expo learner app, the Next.js learner site and the Next.js CMS. The app ships over the air with EAS Update, gets push from OneSignal, sells subscriptions through RevenueCat and reports to Firebase.
  • Components:
    • OneSignal (push)
    • RevenueCat (subscriptions)
    • Firebase (GA4 · Crashlytics)
    • EAS Update (OTA releases)
    • Learner app · Expo: Push (permission flow), built by me
    • Learner app · Expo: Subscriptions (paywall), built by me
    • Learner app · Expo: Analytics (typed events), built by me
    • Learner app · Expo: Video · audio (players · resume), built by me
    • Learner app · Expo: Learning (quiz engine), built by me
    • Learner app · Expo: Streaks (badges · ranks), built by me
    • Learner app · Expo: Scaffold (UI kit · team)
    • Skillify API (shared backend)
    • Learner web · Next.js: Learner site (multi-tenant)
    • Learner web · Next.js: Lesson player (subtitles · seek), built by me
    • CMS · Next.js: Admin CMS (content builder)
    • CMS · Next.js: AI translation (+ Drive import), built by me
  • Connections:
    • OneSignal to Push
    • Subscriptions to RevenueCat
    • Analytics to Firebase
    • EAS Update to Learner app · Expo
    • Learner app · Expo to Skillify API: REST
    • Learner web · Next.js to Skillify API: REST
    • CMS · Next.js to Skillify API: REST

Key decisions

  1. An unknown payment stays unknown

    After a timeout, a read counts as 'not charged' only 60 seconds after the POST, so a late server success never meets a second tap.

    Problem, choice and trade-off: An unknown payment stays unknown
    Problem
    Buying diamonds or restoring a streak is a slow POST. After a timeout or a 502/504 the app re-read the balance at once, and the fast read could say 'not charged' before the server had committed.
    Choice
    A read counts as 'not charged' only 60 seconds after the POST was sent. Until then the screen says the payment is not confirmed yet, with Check again, Buy stays disabled, and one automatic re-check runs just after the window. A read that finds the charge settles it at any time.
    Why
    A late server success must never meet a second tap, and the lock is always on screen, so the learner knows why Buy is off.
    Trade-off
    After a flaky request a learner can wait up to a minute with Buy disabled.
  2. Locked phone does not sign out

    A failed or hung keychain read becomes 'unknown' and retries instead of signing the learner out, and new tokens stay readable after the first unlock.

    Problem, choice and trade-off: Locked phone does not sign out
    Problem
    Session tokens were stored in a keychain class that cannot be read while the phone is locked, and the app treated a failed read as signed out (a P0).
    Choice
    New token items are written as AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY. A failed or hung read (3 s timeout) becomes 'unknown' and retries instead of signing out, refused writes retry with backoff, and an older value never overwrites a newer one.
    Why
    Items stay readable when the phone locks again after the first unlock, and they never move to another phone through a backup or iCloud, which is Apple's and OWASP MASVS's advice for credentials.
    Trade-off
    Users sign in again on a new phone. I did not migrate existing items: delete-then-add is not atomic, and a kill between the two would sign the user out, so old items keep their class until the secure-store upgrade.
  3. One token refresh for every tab

    A single-flight refresh: the first caller refreshes and every other request and tab awaits the same promise, so rotating tokens never race.

    Problem, choice and trade-off: One token refresh for every tab
    Problem
    When an access token expired, several requests (and several browser tabs) refreshed at once, raced each other and logged the learner out.
    Choice
    A single-flight refresh: the first caller refreshes, everyone else awaits the same promise, and other tabs pick up the new session instead of refreshing again.
    Why
    Refresh tokens rotate, so two parallel refreshes invalidate each other; one flight makes the race impossible.

Credit. The app scaffold and UI library, the multi-tenant domain system and most of checkout were built by teammates.

Next case

Interactive interiors

3D web · architecture visualisation