Aangoo
A financial-literacy platform built around AI-personalised courses, financial tools and expert insight. I was the sole frontend engineer across all three of its shipped surfaces.
Role
Stack
Live
Overview
Aangoo helps people build money confidence through AI-personalised courses, financial tools, expert-written insights and a community around them. I was the sole frontend engineer, working alongside a backend developer, an ML engineer building the personalisation layer, a product designer and a product manager.
Three surfaces shipped, and I built and owned all of them: the v1 beta; the v2 product — a single React app serving both the public marketing site and the authenticated product, sharing one component library, one auth layer and one route-guard system; and the admin dashboard, an internal CMS covering courses, articles, users, subscriptions, finance and site-wide branding.
I also wrote the transactional and lifecycle email templates — 16 hand-built, table-layout HTML emails covering verification, password reset, a three-email welcome drip, onboarding nudges, course-started and course-completed notifications, certificate-ready, community invite, subscription expiry warnings and inactivity re-engagement.
The problem
I joined and shipped v1 in two weeks. Once it was live we went straight into v2 — a far more robust rebuild, delivered in under five months against a six-month deadline. Frontend development had to keep pace while the APIs, the designs and the ML-driven personalisation logic were all still being finalised and revised in parallel, with me as the only engineer covering the frontend.
Two constraints shaped most of the technical decisions. Most learners were on mobile, often on inconsistent connections. And the platform is video-heavy, which made video storage and egress the hard budget ceiling on what the delivery pipeline could ever look like.
Key decisions
A PWA, deliberately a thin one
Most learners were on mobile, so a PWA gave an installable, app-like experience without maintaining separate native apps. I wrote the manifest and service worker by hand rather than pulling in a plugin, because the app only needed two things: installability and a cached shell. It precaches the shell, runs a network-first fetch strategy that falls back to cache when the network fails, and clears stale cache versions on activate.
The tradeoff
This is not a true offline experience. A learner who loses connection mid-lesson gets the shell and cached responses, not offline video. That was the right scope for the time available, and it kept the caching behaviour small enough to reason about.
An in-house CMS instead of a hosted one
The beta used Contentful for blog content, which meant a third-party dependency, a separate editorial surface, and content the product team could not fully control. For v2 the admin dashboard became the single content system: course authoring, article publishing, user management, subscriptions, finance views, analytics and site-wide branding. That last piece matters more than it sounds — logos, favicon, app icon, Open Graph images, email logo, social links, partner logos, FAQs and testimonials all live in the admin and are pulled into the public site at runtime through a branding context. The marketing site is content-managed, not hardcoded.
The tradeoff
Significant upfront build time, in exchange for the content and product teams never needing an engineer to publish or change anything.
A design system, not a component library
No third-party UI kit — Figma became the source of truth instead. Around 100 screens across the three apps share one token file for colour, type and breakpoints, and a small set of variant-driven primitives (Button, InputGroup, Modal) that get composed into new screens rather than restyled from scratch. Breakpoints alone run through one helper file, called hundreds of times across the product, with almost no hand-written media queries anywhere else.
The tradeoff
Tokens covered colour, type and breakpoints well, but spacing stayed a convention rather than an enforced rule. Building the system took longer upfront than dropping in a component library would have, in exchange for every new screen looking and behaving consistently without someone having to catch drift after the fact.
No Redux — React Query for server state, Context for the two things that are actually global
Almost all state in this product is server state, so TanStack Query does the real work: a five-minute staleTime, a thirty-minute gcTime, and per-query control over refetch behaviour. Only two things are genuinely global and non-server, so only those get Context: the auth token pair and the branding payload. I also added selective cache persistence to sessionStorage that persists exactly one query key, user-data, so a page refresh does not blank out the dashboard while the user object refetches.
The tradeoff
Persisting everything would have meant serving stale course and progress data across reloads, which is worse than a brief loading state.
Route access as declarative conditions rather than scattered checks
Both v2 apps use one guard component that wraps route groups and takes a list of named conditions: must be logged in, must be logged out, must be an admin. Auth state reads through an accessor that falls back to sessionStorage, so a refresh does not log you out. A failed permission check does not just redirect — it clears storage, resets the auth context and notifies the user, so someone who reaches a page they should not have access to is fully signed out rather than left holding a session in a half-authorised UI. Underneath that, an axios response interceptor catches 401s, refreshes the access token once per request to avoid refresh loops, retries the original request, and logs the user out with a session-expired message if the refresh itself fails.
Image optimisation at two layers, build-time and runtime
The static asset set was fully converted to WebP with zero PNGs remaining, and icons ship as SVGs imported as React components. Images across course cards, blog cards, insight cards and every marketing section use native lazy loading. The second layer is runtime: the admin compresses images in the browser before they ever reach S3, using a web worker to convert any uploaded image to WebP, cap it at 1MB and 1920px on the long edge, with a fallback to the original file if compression fails on a corrupt image. That means content the team uploads years from now is still WebP without anyone remembering to do it. Upload slots also enforce their own rules where it matters — Open Graph images are rejected unless they are exactly 1200×630 and under 400KB.
The hard part
The hardest problem was the video pipeline, and it ended up being hard on both ends.
Getting video in
Course videos are large, and pushing them through the API server was not viable. The admin uploads them straight to S3 using backend-issued presigned URLs. On submit, the course builder walks the form data, finds every file that needs uploading — thumbnail, every class video across every module, every attached resource — requests a presigned URL for each, PUTs the file directly to S3 with live per-file progress tracking, and swaps the returned permanent URL back into the payload. Only once every file has resolved does the assembled course data go to the backend.
Uploads run sequentially rather than in parallel. That is slower, but it keeps the progress indicator honest and avoids saturating an admin’s connection with several hundred megabytes of concurrent uploads.
Getting video out
This is where I want to be precise about what actually shipped. I built toward CDN-backed adaptive streaming — serving multiple quality renditions and selecting between them based on measured network conditions — because a large share of users were on unreliable mobile networks and buffering directly threatens course completion. We stopped before shipping it. Generating and storing multiple renditions per video multiplied S3 storage and egress cost past what the project could carry, and the right call was to cut it rather than ship a feature that would quietly break the budget.
What shipped instead is a single-source HTML5 player built to survive a bad connection rather than adapt to one. It resumes from the exact saved position, persists progress to the backend on a throttle rather than on every playback tick — which fires several times a second and would have hammered the API — marks a lesson complete within a tolerance of the end so a learner who stops a few seconds short still gets credit, and keeps the course sidebar and dashboard in sync after every save. It handles two different progress models, one for standalone video courses and one for modular courses with per-class tracking, plus platform-specific handling for iOS video load failures and mobile Safari’s media menu behaviour.
The course builder behind it
Authoring a modular course means editing deeply nested state: a course contains modules, modules contain classes, a class is one of video, reading or quiz, and a quiz contains questions which contain options which each carry a correctness flag. Every edit is an immutable update several levels deep. Modules and classes are reorderable by drag with keyboard and touch support, and the rich text fields are built on TipTap with a Notion-style slash menu I built from scratch: it detects a trailing /, positions itself at the caret, and offers headings, lists, checklists and image insert that uploads to S3 and writes the returned URL back into the document at the saved cursor position. Validation runs per stage, so the builder will not let an admin advance with a quiz question that has no correct answer or a video class with an empty recap.
One smaller piece I like: the article editor can import an existing post by URL. It fetches the page, extracts the clean article content, and drops the title and body straight into the editor, turning republishing an existing piece from a several-minute copy-paste-and-reformat job into pasting a link.
Where it landed
In the first three months after v2 launched, the platform converted 20+ paying customers from its first 100 signed-up users and logged over 10,000 course views. User feedback on the experience was consistently positive.
What I took from it
Cutting a feature you have already built toward is a real decision, not a failure. Adaptive streaming was the right idea on the wrong budget, and shipping it would have broken the project quietly.
Almost everything in a product like this is server state. Reaching for a global store first is how you end up maintaining a second cache you never needed.
Decide it once, declaratively — the route guard, the upload rules, the image compression — and the rule still holds years later, when nobody remembers it is there.