Skip to content
Ezequiel Olivero

Case study2026PersonalSolo buildLive

Sunnsteel

Build a routine, train against it, and let the numbers move themselves.

The Sunnsteel dashboard: the current rank, Artisan, earned with 46 sessions over 11 active weeks, beside what the next rank still needs; today’s workout, Lower B - Hinge focus of the Upper / Lower - Autumn Block routine inside its Autumn accumulation training block, with Start and Details; and this week’s strip of three rest days, two completed days and today planned.
Fig. 01Dashboard — rank, today’s workout and the week at a glance

Summary

The whole project on one screen. Every feature and decision listed here links to its full write-up below.

Role
Solo — product, design, full stack
Timeline
2026 — present
Status
Live in production
Codebases
3 codebases (2 apps + 1 npm package)
Frontend
Next.js 15 · React 18 · TypeScript
Backend
NestJS 11 · Prisma 6
Data
PostgreSQL 17 · Prisma 6
Tests
900+ automated tests across both apps, CI-gated

What I owned

  • Product design and information architecture
  • Frontend: App Router client, component system, all state and caching
  • Backend: REST API, authorization, data model and migrations
  • The shared contracts package and its release process
  • CI, deployment and the local dev tooling

Overview

Sunnsteel is a training platform for people who follow a written programme rather than improvising in the gym. You describe a routine once — which days you train, which exercises run on each day, how many sets, what rep range, how long to rest — and the app turns it into a schedule you execute set by set, measures what actually happened, and reminds you when the next session is due.

Around that core sits everything a programme needs to last a year rather than a month: saved versions, dated training blocks and deloads, strength trends and a plateau watch, ranks earned by showing up rather than by weight lifted, and a social layer where members follow each other, train as partners and share routines, with blocking, reporting and moderation underneath. It installs like a native app, works in English or Spanish, and has shipped more than 120 features across ten areas.

The part that makes it more than a logbook is progression. Each exercise carries a scheme that decides what happens after a session is finished: hold the load, add reps until the top of the range and then add weight, or advance each set independently. When you close a session the app applies that rule and rewrites the next one, so the programme moves forward without anyone editing a spreadsheet.

It is built as two deployable TypeScript applications — a Next.js client and a NestJS API — with a third package holding the types they exchange. That last piece is the load-bearing decision: it is what stops the two halves drifting apart, and it is described in full further down.

Architecture

System topologyNumbered badges follow one request; the steps are listed below.

Three codebases

sunnsteel-frontend
Next.js App Router client, rendered entirely client-side · TypeScript
sunnsteel-backend
NestJS REST API — auth, persistence, scheduled jobs, Web Push, metrics · TypeScript
@sunsteel/contracts
Published DTOs, enums and response types shared by both · TypeScript

One request, end to end

  1. A route component renders and calls a typed hook — it never fetches anything itself.
  2. The hook reads from TanStack Query; on a cache miss it delegates to a service function.
  3. The service issues the HTTP call, attaching the Supabase access token as a bearer credential.
  4. A NestJS guard verifies the token locally against Supabase’s published signing key, with no call to Supabase, and upserts the caller into the local users table.
  5. The controller validates the body against a DTO — unknown fields are rejected rather than ignored.
  6. The service runs the business logic and reads or writes through Prisma.
  7. A mapper converts the Prisma row into an @sunsteel/contracts type before serialisation.
  8. TanStack Query caches the typed response, and every component subscribed to that key re-renders.

Stack by layer

Feature deep-dives

Also in the app

Six features earn a deep dive above. The rest of what shipped, named rather than explained.

Profiles and the social graph
A lowercase @username, a structured training identity and featured accomplishments; follow and unfollow, followers and mutual lists, training partners, and an activity feed with themed reactions, comments and encouragement; member search with per-account discovery controls; blocking, reporting and a moderation queue; and a public profile a signed-out visitor can open.
Achievements
Fixed milestones for completed sessions, logged sets and volume moved; Renaissance ranks earned through attendance rather than weight lifted, each with its own crest, leading the member’s profile and dressing its header more richly at every rank; progress toward the next milestone; and comeback recognition for returning after time away.
The exercise catalogue
Every exercise with its primary and secondary movers, movement pattern and equipment, filterable down to what one gym actually has, plus starred exercises and ones you add yourself, each with a page carrying your best set, estimated one-rep max and the records it earned.
The dashboard
Your current rank and exactly what the next one needs, one clear next action rather than a wall of buttons, this week at a glance, weekly and lifetime totals, current and best streaks, a personal-records wall, insights drawn from your own last two training weeks, and updates from the members you follow, in an order you choose.
History and sharing
Paginated, filterable history where any finished session opens into its full set-by-set log and notes, can still be corrected for a short while after it ends, and can be shared by link showing exactly the parts you choose — revocable whenever you like.
Notifications
A bell carrying an accessible unread count that opens the latest updates in a menu, a page listing what is actionable today, and alerts when a training partner finishes a workout or earns an achievement, each kind switchable on its own, with quiet hours.
Settings and preferences
English or Spanish; kilograms or pounds chosen once and respected everywhere; per-section profile privacy; registered gym equipment that feeds the plate calculator; reduced motion, higher contrast with larger controls, and a gym mode; light or dark themes; and your data downloadable, or your account deletable, at any time.

Engineering decisions

  1. A published package as the API type boundary

    Problem
    Two separately deployed codebases exchange JSON. Nothing in TypeScript stops a field being renamed on one side and silently breaking the other at runtime.
    Decision
    The DTOs, enums and response types live in @sunsteel/contracts, published to npm and versioned independently. Both apps depend on it, so a shape change that has not been agreed on fails to compile rather than failing in production.
    Tradeoff
    Every contract change is now a publish-and-bump across two repos before it can be used. That is real friction on small changes, and it means a broken release can block both sides at once.
  2. A mapper layer between Prisma and the wire

    Problem
    Returning Prisma results directly is the fastest way to build an endpoint and the fastest way to leak a column — internal ids, timestamps, soft-delete flags, anything added to the schema later.
    Decision
    Every response passes through a mapper that converts the database row into a contracts type. The API surface is defined by what the mapper produces, not by what the table happens to contain.
    Tradeoff
    Boilerplate per endpoint, and one more place to edit whenever a field is genuinely meant to be exposed. It is a tax paid on every route to make one class of accident impossible.
  3. Two-tier auth: a cookie for routing, a JWT for authorization

    Problem
    Route protection wants something readable synchronously during a middleware pass. Authorization wants a credential the server verifies per request. Making one mechanism do both jobs means compromising one of them.
    Decision
    They are separated. An HttpOnly cookie acts purely as a UX gate so protected pages do not flash before redirecting. The Supabase JWT, verified on every API call, is the only thing that actually grants access to data.
    Tradeoff
    Two pieces of session state that can disagree — a valid cookie with an expired token renders the shell and then fails its first fetch. Handling that gap is deliberate work, and the failure mode is documented in the repo rather than discovered again later.
  4. Verify tokens locally, not with a call per request

    Problem
    The API first asked Supabase Auth to validate every bearer token. That put a network round trip in front of every protected request and made every data read depend on Supabase Auth being up: over 22 and 23 September its logs showed 7,680 validation calls against 45 token refreshes.
    Decision
    The guard checks the token’s ES256 signature against the project’s published key set, cached in the process, then requires the right issuer, audience, role and subject itself. One protected read went from 274.9 ms to 8.3 ms at the median, measured locally with a real token.
    Tradeoff
    A token now outlives the session behind it until it expires, up to an hour, because nothing asks Supabase whether that session is still alive. Account deletion made the gap concrete, so a member the database does not know is still checked with Supabase, and only then.
  5. Persisted analytics instead of lifetime scans

    Problem
    Records and progress were derived from a member’s entire set-log table on every read, so the cost of opening Progress grew with everything they had ever logged.
    Decision
    Personal records, immutable training events, daily and weekly muscle rollups and a routine snapshot per session are persisted, written atomically when a session finishes under a per-account lock, and rebuilt by a background backfill. Every Progress read is bounded: four to twelve weeks, capped event scans, cursors.
    Tradeoff
    Much more write-side machinery: projections, backfill jobs, snapshot provenance, SKIP LOCKED claiming, and a lock every writer must take in the same order. Correcting a finished workout has to rewrite its events and rollups exactly, which is why only the latest one can be corrected.
  6. A generated service worker that keeps its own rules

    Problem
    The first PWA shipped a small hand-written worker, so revisioning and cache lifecycle were owned by hand. That produced stale assets hiding current code, unsafe activation, overly broad cache deletion, and a manual cache-version bump on every change.
    Decision
    Serwist builds the worker during the production build, with a precache manifest generated from that build’s own output. The policy stays explicit and tested: network-first pages with an offline fallback, stale-while-revalidate for static files, nothing cached for the API, and activation deferred until an active workout ends, so an update never interrupts a live session.
    Tradeoff
    The production build now depends on a webpack plugin while development runs on Turbopack and skips it, and a first install downloads the whole build, about 3.5 MB, rather than only what the visitor opens.

What’s next