06Aug. 2026Sole developerShipped

Recomp

Built for one, architected for many.

A body recomposition tracker on Next.js 16 and Supabase, with a fixed bodyweight programme, daily logging, Recharts progress views, and a Gemini coach grounded in your own history. Twelve tables, row level security on all of them, deployed for one user.

Demo account

Email
demo@recomp.app
Password
RecompDemo2026!

The problem

Every fitness app I tried wanted to be a social network, a marketplace or a subscription. I wanted to log a weight, tick off a workout, and see whether the trend was going the right way.

The coaching angle was the part worth building. General advice is easy to find and mostly useless, because it does not know that you missed two sessions last week or that your weight has been flat for a month. Advice grounded in your own logs is a different thing, and it needs the logs to exist first.

The constraints

Free tiers throughout, which meant Supabase and Vercel, and Gemini for the model because its free allowance is generous enough for a chat feature that runs a few times a week.

One user, me. That is a deployment fact I decided not to let become an architectural one.

What I built

A Next.js 16 app on the App Router. A four-day bodyweight programme with the day's session on the dashboard, daily weight and nutrition logging, and a weekly check-in covering energy, sleep and sessions completed. Progress views in Recharts for weight trend, consistency, strength and nutrition. A coach endpoint that reads your Supabase data server-side and sends it to Gemini as context.

Twelve tables in Postgres, all with row level security.

What this is and is not

It is a real multi-tenant application. Signup works for any number of accounts with fully isolated data, enforced by row level security in the database. It is deployed for one person, which is a fact about the deployment rather than about the schema.

The coach is not qualified and is not a coaching product. It reads your logs and responds. It has no medical knowledge, no rails around extreme inputs, and no awareness of injury. It is a well-informed chat interface over your own data, and it should be described that way.

The programme is fixed rather than generated or adaptive. Four days of bodyweight training, the same four days.

Email confirmation is switched off, which is the one setting configured for solo use rather than a public launch. Anyone could sign up with any address. It is a single toggle away from being correct, and it is why the app is not linked publicly.

The technical decisions

Decision Reasoning
Row level security on every table, scoped to auth.uid() Isolation in the database rather than in my query code. A forgotten where clause becomes a bug rather than a data breach
Service-role key confined to lib/supabase/admin.ts, guarded by the server-only package It is needed for exactly two things. Importing that module into a client component becomes a build error instead of a leaked credential
Middleware excludes /api Redirecting an unauthenticated request is right for a page and wrong for an API route, where it turns a 401 into a 200 carrying HTML. API routes check auth themselves and return real status codes
Server Actions colocated with their page actions.ts sits beside the form that calls it. Finding the handler means looking in the folder you are already in
Recharts rather than a canvas charting library Charts are React components composed like anything else, so a chart shares the app's theming instead of being a styled island
Domain logic in lib/<area>/, not in components Date and week helpers, types and formatting live together and away from rendering, which is what makes the week-boundary logic testable

The hardest thing

Row level security on a table that has no user.

exercise_logs records individual sets against a workout. It has a workout_log_id and no user_id, because the user is a property of the parent workout. That is correct normalisation and it makes the security policy awkward, because RLS policies are evaluated per row and the row does not know who owns it.

Two options. Denormalise user_id onto exercise_logs so the policy is a direct comparison, which is fast and introduces a column that can disagree with its parent. Or write the policy to join up through workout_logs to establish ownership, which keeps one source of truth and puts a join inside every permission check.

I took the join. The reasoning is that a duplicated ownership column is a correctness risk that never announces itself: if the two ever diverge, the security check consults the wrong one and nothing errors. A join is slower and cannot be wrong. Given the row counts one person generates, paying in milliseconds for an invariant I cannot break was clearly the right trade, and I would revisit it if the numbers changed rather than because it feels expensive.

The broader thing I learned is that RLS makes you state ownership explicitly for every table, including the ones where ownership is indirect. That is uncomfortable at design time and it is the entire point.

What I would do differently

Give the coach a deliberate strategy for choosing what history to send. At present the endpoint reads Supabase and builds context, and how much of a growing log it can carry is a question I have not had to answer yet because there is not enough data to force it. It will degrade slowly rather than break, which is the harder kind of problem to notice.

Add offline logging with a sync queue. The app is used in a gym, and gyms are where connectivity is worst.

Make the programme editable. A fixed four-day plan is the assumption most likely to stop being true for the one person using it.

What came of it

It is deployed and I use it, which for a personal tool is the only success condition that matters.

The part I would point at is the decision to build for multiple users while having exactly one. It cost very little at design time, it forced me to think about ownership on every table rather than assuming a single account, and it means the honest answer to "could someone else use this" is yes, with one toggle, rather than a rewrite.