TypeScript logo but with a P
Patrick Spafford Software Engineer

Building a Weightlifting App with SwiftUI and Supabase


April 9, 2025

14 min read

views

a cartoon man powerlifting a barbell while standing on some bricks.

The App

Find BrickByBrick Fitness, the app discussed in this post, here on the App Store!

Inception

Background

In the spring of 2023, I started keeping a log of my workouts in the built-in notes app for iOS. At the time, I was only “lifting weights” once a week at the Russell Investments Center in downtown Seattle. After hopping on the elevator from the Oracle office to the gym a few floors below, I’d create a new table in a monstrous “workout log” note. I would scroll to the bottom of the note, type out the date, and then copy and paste the previous table. This was easy because I was doing almost the same collection of exercises each week.

While that was an adequate routine for a while, eventually I became frustrated with a few things. After several months, it became cumbersome to scroll to the bottom, copy an ever-larger table of exercises, and avoid accidentally deleting previous tables. I continued to ignore this problem until:

  • the performance of the Notes app began to degrade when making edits in the middle of the workout
  • I started lifting weights more than once a week

So in mid-to-late 2024, I began lifting weights more often because of persistent runner’s knee that made it very painful to squat, kneel, or sit for any significant period. To remain active, I joined a Planet Fitness that had recently opened in my area and started a routine of going about 3 times a week, 30-45 minutes each. At this point, the iOS note had become frustating enough to use that I decided to try managing my workouts with a spreadsheet. The spreadsheet structure looked something like this:

Exercise NameWorkload GoalDate NDate N - 1Date N - 2
Dumbbell bench press4,10,55;1,3,60
Some exercise name4,12,25

The leftmost columns (Exercise Name and Workload Goal) were also “pinned” to the left hand side of the sheet for convenience.

At this point, I had more exercises to choose from (it being a commerical gym) and I needed to keep my lower body happy while I recovered. So I chose exercises for that day based on how far I had to look back (sometimes scroll) to the right on the spreadsheet. So each time I went to Planet Fitness, the process was:

  • insert a new date column to the right of “Workload Goal”
  • put a ”-” next to the exercise names that I needed to do that day
  • increase the number of sets, number of reps, or weight for those exercises
  • manually type out what sets I completed in that day’s column as I went.

To keep things manageable, the format of each cell in this spreadsheet had a particular syntax.

{number of sets},{number of reps in a set},{weight in lbs per rep}

For example, 4,10,25 would correspond to “4 sets of 10 reps at 25 lbs”.

And if there was more than 1 “group” of sets, I’d separate that with a semicolon. For example, 4,10,25;1,10,30 would correspond to “4 sets of 10 reps at 25 lbs and 1 set of 10 reps at 30 lbs”.

This worked pretty well for a few months, but there were still pain points. Perhaps some of them are defects in the structure of the spreadsheet. (I’m not arguing that it couldn’t be done better, but I suppose the point here is to describe what motivated the eventual creation of the API and app.)

  • User experience at the gym: In between each set, I’d log it. But logging it on an iPhone while at the gym meant:

    • finding the cell (which was tiny)
    • focusing it
    • deleting some number
    • typing a new number
  • Loss of history: Because of the way my spreadsheet was structured, how many reps or what weight I had done on previous days was lost. Looking back at a workout from 2 months prior would only tell you (1) which exercises were done and (2) how many sets were done. This was never a huge deal because the results you care about are physical, but it’s still valuable information to have nonetheless.

  • Consistency of progressive overload: Having been going to the Planet Fitness for a few months, I still wasn’t seeing as much progress as I would have liked. While I occasionally would increase the sets, reps, or weight, there was no rhyme or reason, no methodology by which I was increasing the volume of work. It moved more by intuition than by analysis.

  • Time spent planning: With more exercises in the rotation, picking the “freshest” exercises and increasing the workload was a chore that I typically did after parking the car. This was a simple yet repetitive task that could be automated.

  • Retiring certain exercises: Over time, I became more strict about only doing exercises that I could progressively overload. There’s something fundamentally demotivating to me about the idea of going to the gym to “maintain” strength instead of “increasing” strength, even if that increase is modest. As a result, some exercises were removed from the rotation, but I didn’t want to delete them entirely from the spreadsheet. Just in case I relaxed that requirement later on and wanted to know (roughly) where to pick back up. Keeping those rows in the spreadsheet made it slightly harder to tell which exercises were “freshest” since they were technically next in line, but out of commission for the time being.

After combining these pain points with the a hunger to create something new in my spare time, I decided to write an app for this. And if other people ran into the same problem as I did (in terms of progressive overload and ease-of-use), making an app for it would solve it for them, too.

Caveats

I’m aware that there are plenty of other workout apps out there, so there is plenty of competition in this space. However, I refused to let that be a reason to not do it. I was solving this problem for myself in the narrowest sense. But in a broader sense, I was aiming for a particular niche in the workout app space, one that prioritized user experience and gradual progressive overload.

Even if I failed to deliver on that goal, bootstrapping a subscription-based SwiftUI app with a serverless backend was both an exciting prospect and also (arguably) a valuable experience. And if I made some additional money on the side, great.

I wouldn’t even argue here that I necessarily chose the best tools to achieve this goal. For instance, I believe I could have solved the problem more quickly with React Native and not making a backend at all. The app could have been cross-platform and entirely client-side. That would have had the advantage of lower operational costs and a wider user base. Using React Native would have had its own tradeoffs, like making it more difficult to achieve a native look and feel. Not having a backend would make it impossible to manage things using Infrastructure as Code tools, impossible to persist workout data for users who delete and reinstall the app.

That said, as a developer, part of doing this project was about sharpening my skills and developing new ones. Learning SwiftUI in a hands-on way, learning more about building APIs from scratch, and learning the tools that go along with those, respectively.

Concepts

In this section, I lay out some of the core concepts to the app, which may serve as a decent handbook until there are FAQs on the app website.

Anatomy of a Workout

A workout is a collection of exercise sessions. An exercise session describes the work you are going to do for a particular exercise on a given day. For example, your workout for today might consist of 3 exercise sessions: leg curls, shoulder presses, and back extensions.

An exercise session itself consists of 1 or more groups of sets. I dubbed these exercise set groups (or “set groups” for short). For example, your leg curl session for today might consist of 2 groups of sets: 1 set of 10 reps at a lower weight (for a warmup) and 6 sets of 12 reps at a higher weight.

The hierarchy is like this:

Workout > Exercise Session > Exercise Set Group

Workout-Planning Algorithm

When a user creates a workout, that workout is created according to a specific strategy.

A strategy defines exactly how workouts will be created and automatically overloaded for a specific user. A strategy defines things like what exercises are the rotation, how much to overload exercises by per workout, the target number of sets for a given exercise session, etc.

So when the user taps “Start workout”, the app sends a request to the API to create a workout. The API then does the following:

  • Creates a new workout entity
  • Chooses the least-recently-used (LRU), active exercises from the current user’s strategy
  • Creates new exercise sessions, associating them with the new workout
  • Creates new exercise set groups, increasing the goal where applicable, associating them with an exercise session
    • This is where the progressive overload happens!
    • The API increases the volume (weight * reps * sets) of that exercise session.
  • Some other things behind the scenes to track how many workouts the user has created in their lifetime. For billing purposes.

Boom! The user now has a planned workout that handled the progressive overload and exercise selection for them. And if the user wants to customize how their workouts are planned, they have a lot of control over how that is done: They can edit the strategy!

Obviously, in practice, there’s more nuance to it than this (handling edge cases), but there’s no rocket science here. It’s a lot of bookkeeping (if you will) downstream of using a relational database.

Front End

The front end uses SwiftUI, Supabase Auth, and some “optimistic UI” to keep the user experience buttery smooth.

SwiftUI

SwiftUI is a declarative UI framework made by Apple which I suppose is intended to replace the old way of creating user interfaces with view controllers and storyboards. In web developer terms, you can think of SwiftUI as essentially Apple’s own “React” using native iOS components. This was fun to work with for the most part, and it makes for a native-feeling and zippy UX.

That said, I found it be a little too opinionated about declarative routing. Programmatic routing is possible, but was quite slow for me. I switched back to declarative routing to improve the user experience, but I had to make some sacrifices along the way. For example, when creating a new workout, you’d expect there to be an easy and fast way to wait for some asynchronous process (workout creation) to succeed before navigating to another view. But there’s not. So I had to pivot to immediate navigation and then running the asynchronous process onAppear. The reason this makes a difference is because now the logic for that destination view is more complicated. The destination view should receive a “workout” struct or object, not an optional one.

Setting default props on view components was also more tedious than with React. For example, the following React is trickier to replicate in SwiftUI:

// REACT

// Parent view:
<MyCounter startingCount={3} offset={1}  />

// Component definition:
...
const [myCount, setMyCount] = useState<number>(startingCount + offset) // Works and starts at 4.
const [someOtherState, setSomeOtherState] = useState<unknown>()
...

In SwiftUI, because views are structs, to do the same thing as in this example, you have to override the constructor (init). Which is fine, but it’s now all-or-nothing. Your custom initializer / constructor must initialize every non-optional prop. And that gets complicated if you’re using things like @StateObject and @Environment.

Optimistic UI

To solve one of the pain points I had with the spreadsheet, the experience of logging sets at the gym had to be excellent. That’s why I opted for “optimistic UI” in some areas. For example, when a user logs a set, that sends a request to the API to bump the number of sets by 1. However, forcing users to wait for this to load is a bad user experience. So, at the risk of a potential rollback, the UI assumes that the request will be successful (this is the optimistic part) and instantly increases the number of sets, firing a request to the API at the same time. Once that request succeeds, the “fake” data is replaced with the “real” data from the API.

Design Patterns

The UI employs the MVVM design pattern. Which (given the prevalence of ObservableObject) is seemingly the common pattern with SwiftUI apps. More broadly than that, though, I tried to keep the logic in the user interface as “dumb” as possible. Most of the business logic should exist with the API. Even the way data is sorted should be controlled by the API. As a result, the UI can be simple and robust. In addition, updates to the workout-planning algorithm can evolve independently of the UI; no need to wait for a review and approval on App Store Connect before pushing a fix. It also means that if I wanted to support a web client, that would be easier since I don’t have to rewrite the logic for that client in JavaScript.

Back End

The back end is a serverless, RESTful API that uses Supabase Edge Functions, PostgreSQL, Deno, and TypeScript.

The backend uses the Supabase client to interact with Postgres. In a “typical” Supabase application, the logic lives on the client-side. So you might initialize the Supabase client in the browser or in the app, but in this case, the Supabase client runs on the server side.

Serverless

By making the API serverless, I can strike a good balance in terms of having a backend and keeping operational costs quite low. Until there’s half a million requests, the backend is basically free. No need to pay for a compute instance, a DB instance, etc. If traffic spikes, the API can scale to handle it. The fact that there’s low latency is nice, too.

Using Deno and TypeScript was a natural choice for the edge function runtime and programming language, respectively. Edge Functions are TypeScript first and Deno is the recommended (only?) runtime. Prior to this project, I hadn’t had much experience with Deno. I appreciated what I got out of the box (not needing to configure TypeScript and a linter myself). All I had to do was install the Deno VS Code extension.

Routing

One interesting problem I had to solve was routing requests. I wanted the workout API to consist of only 1 Edge function to avoid issues with cold starts, so that necessitated routing requests to different paths (GET /api/workouts, PUT /api/workouts/123, etc).

The Supabase docs lay out some well-defined ways of doing routing in Edge Functions, but I decided to roll my own router. I didn’t like the idea of adding a web framework and this was an opportunity to learn something, so the API routing logic is totally custom. This was quite fun and making it recursive made it feel like a LeetCode problem.

Denormalization

While I was aiming at an iOS app with SwiftUI all along, I made the serverless API first. As you might imagine, the API and the UI co-evolved. To keep the UI as simple as possible, I ended up denormalizing a lot of API responses so that the UI could more easily fit into the

ui = f(state)

mental model. For instance, when you fetch a workout, the response also contains data about the exercise sessions and the set groups in that response; there’s no need to make a nested bulk query to go get those things (n exercises sessions x m exercise set groups). In other words, it contains all the data that the UI needs to show all details of the currently-active workout. The data / state flows in one direction and from one source of truth: the GET workout API call. It also reduces the number of API calls, so that could reduce the infrastructure costs down the road.

I suppose the responses could have been left alone in favor of adding a GraphQL route, but this was the direction I went with it.

Conclusion

Overall, I really enjoyed the process of making and publishing this workout app.

I learned a lot about SwiftUI, some major things include:

  • Thread safety
  • async-await (as compared to JavaScript’s)
  • ObservableObject vs. StateObject vs. State vs. Binding
  • The MVVM pattern
  • Integrating with StoreKit 2

On the back end, I practiced:

  • designing a relational DB schema to solve a problem
  • setting up row level security (RLS) through Supabase
  • rolling my own Edge Function router, and a lot more.

Feel free to download my app, BrickByBrick Fitness, on the App Store for iOS. Thanks for reading.