At a glance
- Role
- Solo — product, design, full stack
- Timeline
- 2026 — present
- Status
- Live in production
- Codebases
- 2 apps + 1 npm package
- Frontend
- Next.js 15 · React 18 · TypeScript
- Backend
- NestJS 11 · Prisma 6
- Data
- PostgreSQL · Redis
- Tests
- 75+ Vitest specs, CI-gated
Overview
Sunnsteel is a training app 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 can execute against, session by session.
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.
My role
- 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
Architecture
Three codebases
- sunnsteel-frontend
- Next.js App Router client, rendered entirely client-side · TypeScript
- sunnsteel-backend
- NestJS REST API — auth, persistence, scheduled jobs, metrics · TypeScript
- @sunsteel/contracts
- Published DTOs, enums and response types shared by both · TypeScript
One request, end to end
- A route component renders and calls a typed hook — it never fetches anything itself.
- The hook reads from TanStack Query; on a cache miss it delegates to a service function.
- The service issues the HTTP call, attaching the Supabase access token as a bearer credential.
- A NestJS guard verifies that token against Supabase and upserts the caller into the local users table.
- The controller validates the body against a DTO — unknown fields are rejected rather than ignored.
- The service runs the business logic and reads or writes through Prisma.
- A mapper converts the Prisma row into an @sunsteel/contracts type before serialisation.
- TanStack Query caches the typed response, and every component subscribed to that key re-renders.
Stack by layer
Frontend
An SPA on top of the App Router — every read goes through one caching layer.
- Next.js 15
- React 18
- TypeScript
- Tailwind CSS 4
- shadcn/ui
- Radix UI
- TanStack Query 5
- React Hook Form
- Zod
- Framer Motion
- date-fns
Backend
Modular NestJS: auth, users, routines, workouts, exercises and metrics each own a module.
- NestJS 11
- Passport
- passport-jwt
- class-validator
- class-transformer
- Throttler
- @nestjs/schedule
- prom-client
Shared contracts
One published package, versioned independently of either app that consumes it.
- @sunsteel/contracts
- TypeScript
- Semantic versioning
Data & infrastructure
Postgres through Prisma, with migrations applied automatically on release.
- PostgreSQL
- Prisma 6
- Redis
- Supabase Auth
- Vercel
- prisma migrate deploy
Quality & delivery
Every push runs the same four gates that run locally.
- Vitest
- GitHub Actions
- ESLint
- Prettier
- npm audit
- PowerShell dev scripts
Feature deep-dives
F.01 — Auth Accounts and sign-in
Email-and-password or Google sign-in, with protected routes and clean logout across tabs.
Supabase is the identity provider, but it is not the authority the API trusts blindly. Supabase issues a JWT; a custom Passport strategy on the NestJS side verifies that token on every request and upserts the caller into the local Postgres users table, so application data has a real foreign key to point at rather than a floating external id.
The frontend keeps an HttpOnly cookie as a session marker. Next.js middleware reads it to decide whether to render a protected route or bounce to sign-in. The cookie is deliberately not the credential — see the engineering note below.
- Supabase authenticates the user and issues a JWT.
- The backend verifies the token and upserts the user record.
- The frontend sets an HttpOnly marker cookie for route protection.
- Middleware gates /dashboard, /routines, /workouts, /profile, /settings and /search.
- Supabase Auth
- Passport
- passport-jwt
- Next.js middleware
F.03 — Routines The routine builder
A multi-step wizard that turns a written programme into a schedule the app can execute against.
The wizard runs basic info, then training days, then a per-day exercise builder, then a review step before anything is written. Each exercise is configured with a set count, either a fixed rep target or a rep range, rest seconds, and free-text notes.
Exercises come from a catalogue tagged with primary and secondary movers plus equipment metadata, which is what lets the app attribute volume to muscle groups rather than just counting sets. Finished routines can be favourited, duplicated, edited or marked complete.
- Basic info — name and description.
- Training days — which days of the week the routine runs.
- Exercise builder — sets, reps, rest and progression, per exercise per day.
- Review and create.
- React Hook Form
- Zod
- shadcn/ui
- Radix UI

Fig. 02 — Choosing training days, with presets for the common splits F.04 — Sessions Live sessions and set logging
Start a session from a scheduled day and log reps, weight and RPE set by set, with each row saving as you go.
Every set row carries its own save state — idle, saving, saved or error — so a failed write is visible on the row that failed instead of behind a single global spinner. That matters in a gym on bad reception, which is the normal case rather than the edge case.
Sessions persist and can be resumed, track their own duration, and accept notes. History is filterable, and a past session opens into a planned-versus-actual view so you can see where you deviated from the programme.
- TanStack Query
- Framer Motion

Fig. 03 — Logging sets live, with each row saving on its own F.05 — Progression The progression engine
Finishing a session rewrites the next one — the rule is configured per exercise, not applied globally.
Three schemes are available. None holds the prescription steady. Double progression adds reps until the top of the range is reached across all sets, then adds weight and drops back to the bottom of the range. Dynamic double progression applies that same logic to each set independently, so a set that stalled does not hold back the sets that did not.
The rule runs server-side on session completion, which keeps a single implementation of the maths and means the next session is already correct whenever and wherever it is opened.
- NestJS
- Prisma
F.06 — Dashboard Dashboard and records
Streaks, training volume, today’s scheduled work, a recent activity feed and personal records.
The dashboard answers the two questions that actually get asked at the start of a session — what am I doing today, and am I still on track — before it shows anything else. Personal records are derived from logged sets rather than entered by hand, so they cannot disagree with the history they came from.
- TanStack Query
- date-fns
Engineering decisions
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.
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.
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.
A hand-written service worker instead of a PWA library
- Problem
- The generated service workers were opaque at exactly the moment they matter — a stale cache that will not clear on a phone in a gym, with no obvious way to reason about why.
- Decision
- The worker is written by hand: network-first for HTML so a deploy is picked up immediately, stale-while-revalidate for static assets, and explicit cache versioning with activation logic that clears the previous generation.
- Tradeoff
- No library means no community fixes for the browser quirks the libraries already handle, and cache-invalidation bugs are mine to find. Worth it here because the caching rules are few and the failure mode of getting them wrong is severe.
Screens


What's next
- Exercise-level analytics: volume and intensity trends per muscle group over time.
- Programme templates that can be shared between users.
- Offline set logging that reconciles when the connection returns.
