Scratch
An iOS golf app for scoring rounds and running friendly tournaments, built solo from design to deployment.
- Year
- 2026
- Role
- Solo — design, build, deploy
- Status
- In development
- Stack
- React Native
- Expo
- TypeScript
- tRPC
- Zod
- Prisma
- PostgreSQL
- Supabase
- Clerk
- Zustand
- Docker
- DigitalOcean
- GitHub Actions
- EAS

The problem
My friends and I play regular informal tournaments, and none of the golf apps we tried were built for that. They're designed for individual stat tracking or for official club competitions. We wanted something in between: a quality app that makes it easy to score a round, run a tournament across several rounds, and see a live leaderboard as the day goes on.
So I built one. Scratch is currently in TestFlight beta with a group of five testers, who use it for our own competitions.
Constraints
- Solo build, iOS first. Every tester uses an iPhone, so I targeted iOS first and built with Expo so Android stays a realistic option later.
- Golf courses have patchy signal. Scoring can't depend on a connection. A round has to be fully usable offline and catch up later.
- Not everyone wants to use the app. Some players would rather keep a paper card or use a different app. Scratch doesn't require every player to submit their own scorecard: one person can score for the whole group, and the leaderboard still has to end up accurate.
- Hosting costs come out of my own pocket. The architecture had to be cheap to run while it's a hobby-scale beta.
Approach & architecture
The app is written in TypeScript end to end, from the React Native client through the tRPC API to the database schema in Prisma. Scores are written to the device first and synced to a Postgres database hosted on Supabase. Authentication is handled by Clerk. The API runs in Docker on a DigitalOcean droplet, with separate staging and production environments.
Key decisions
The device is the source of truth for scores
Scores are saved locally first, every time. If the phone has no signal, a sync service buffers the changes and sends them to the server once the connection returns. Scoring never waits on the network, and nothing is lost if a player walks out of signal halfway through a hole.
Flag score conflicts instead of guessing
Because more than one person can score the same group, two phones can disagree about a score. Rather than silently choosing a winner with a "last write wins" rule, the server compares scores recorded by players in the same tee time. When they don't match, the affected players get a prompt to check that hole. A golf score is something only the players actually know, so the app asks them rather than guessing.
Type safety from the database to the screen
Type safety was a priority from the planning stage, which is why I chose tRPC with Zod. The API's inputs are validated at runtime and its types flow straight into the app. Changing a field in the backend shows up as a compile error in the client, not a crash on the course.
Keeping auth independent of the database host
I use Supabase for Postgres because I've relied on it before and it's excellent value. I deliberately kept authentication separate with Clerk, though, so the app isn't tied to Supabase if I ever move the database elsewhere. Clerk user IDs are synced to my backend, so each account's data is linked to the authenticated user.
Security works in two layers. The API connects to Postgres through Prisma with a privileged role, so authorisation lives in the tRPC procedures: every request is checked against the signed-in Clerk user before any data is read or written. Row-level security is also enabled on every table, from accounts to scorecards. Because Prisma bypasses it, its job is to lock down direct access to the database, such as Supabase's public API, so nothing can be read or changed without going through the API.
World Handicap System built in
Handicaps follow the World Handicap System (WHS), so the app's net scores match the way the players' real handicaps work.
A proper release process for a solo project
I run two environments: a TestFlight staging build for trying out new features, and a production build for the live app. Unit tests (282 test cases) run in the CI pipeline on GitHub Actions, and EAS over-the-air updates let me ship fixes to testers without waiting for App Store review.
Outcome
- In TestFlight beta, used by five testers for our own tournaments
- Fully offline-capable scoring with background sync
- 282 unit test cases running in CI
- Separate staging and production environments, each deployed with Docker
- Unit test cases in CI
- 282
Next: live GPS distances using a third-party course data API, a Ryder Cup mode (foursomes, four-ball and singles), and a public App Store release.
What I'd do differently
Early on, I realised the way I'd modelled the relationships between scores, rounds, players and courses wouldn't scale as the app grew, and I had to rework the data model partway through the build. If I started again, I'd map out those relationships against future features, such as multi-round tournaments and team formats, before writing the app around them. Changing a schema is cheap on paper and expensive once screens, sync logic and tests depend on it.



