Skip to main content

Engineering at Flokk

Flokk is one Go API, one Postgres database and three clients, all built and run by one engineer.

For what it does and why, see product at Flokk.

TypeScript on the clients, Go on the server

  • Expo and React Native

    The iOS and Android apps share one TypeScript codebase. Store builds go through EAS, and fixes can ship over the air with expo-updates.

  • TanStack Start

    The web app runs React 19 on a Cloudflare Worker.

  • TypeScript

    All client code is TypeScript with Zod for runtime type safety, React Query for data fetching and React Hook Form for forms.

  • Go

    The API is written in Go and uses the Gin framework. All database operations are handwritten SQL with no ORM. The repo is organised by domain.

  • PostgreSQL

    The database is hosted on Supabase, but used as plain Postgres. Supabase provides authentication and storage. All client access is locked with RLS policies.

Changes show straight away, and errors say what went wrong.

Every failure used to show the same “Something went wrong” screen. Now the message says what happened, like being offline or the event being deleted. Only failures that might work on a second try get a “Try again” button, and those are also retried automatically.

Changes are written to the React Query cache before the request is sent, and rolled back if it fails. Each one then refetches only the queries it affects.

Supabase handles auth and hosts the database and storage, but Go owns the data.

To get up and running, Flokk started on the usual Supabase setup with supabase-js and RLS policies.

Now all data access goes through the Go API. It has its own Postgres role with the access it needs, and RLS policies stop the Supabase client from reading or writing any table.

The API is kept simple and flexible; there is no ORM meaning all queries are handwritten SQL.

The API handles image conversions, blurhashes and resizing, and clients only ever see URLs.

The server decides forms and actions.

At first the event form and its rules lived in both the app and the web client. Moving them to the server took away the risk of the two drifting apart, and the wait for an App Store review every time a rule changed.

The API builds the form from the activity and the kind of host. For each field it says whether it is required, optional or not used, and sends its limits and presets. Actions on an event work the same way: the API tells the client what the viewer can do, like join or join the waitlist, and what the button says.

The clients only understand a small, fixed set of field types and actions. Anything inside that set ships with an API deploy. Something new still needs an app release.

Concurrency is handled in Postgres.

On a popular event, people join and leave at the same moment as the host changes the limit. The client can’t be trusted to get that right, so the database does.

Every join or leave first locks the event row, then counts participants in a separate statement. Counting in the same statement as the lock can miss the change it just waited for.

The waitlist is its own table, not everyone past the limit. Lowering the limit never pushes anyone out, and when a spot opens, the next person on the waitlist moves up in the same transaction. The push telling them is sent after.

Feeds are keyset-paginated, anchored at now.

Each page ends with a cursor holding the time of its last event, so new events never shift what comes next. Lists open on the present, with upcoming events scrolling forward and past events scrolling back.

Discovery finds events within a radius the user sets on a map. The distance is calculated in plain SQL rather than with PostGIS.

Images are cropped at the CDN, never re-encoded per size.

People pick an image and crop it in the app or in the browser. The client compresses it lightly and sends the full image to the API, with the crop alongside it as a focal point and a zoom. The upload runs in the background, so creating an event doesn’t wait for it.

The API uploads the image to storage, converts it to WebP and makes a blurhash that clients show while it loads. It also deletes the old file when an image is replaced. Each image is stored once.

Cloudflare serves every size from that one file: the cover, the Instagram Story and the avatar. The API builds the URL for each from the saved crop.

The design rules are written down, and checked where they can be.

The web had grown extra greys, extra blues, two icon libraries and sizes picked page by page. The rules now live in one DESIGN.md file, from the Figtree typeface and a fixed type scale to the colour tokens and Lucide icons.

New pages are built with a design skill. It reads DESIGN.md first and ends by running a script that flags what breaks the rules, like a size off the scale, a raw hex colour or a shadow.

Content is soft-deleted. People are erased.

Deleting used to remove the row outright. A deleted event looked the same as one that never existed, and missing cascades sometimes blocked a deletion altogether.

Now events and clubs are only marked as deleted, and the API answers that they are gone rather than not found. Accounts are different. GDPR and the App Store require a person’s data to actually go, so their login, images and personal details are removed. The profile stays behind, anonymised, so their old replies still have someone to belong to.

Deleting an account also cleans up after it. Upcoming events the person hosts are deleted, or cancelled if people have joined, and the last admin of a club has to hand it over first.

Auth meets people where they are.

Sign-in started with Apple and Google. In Norway many people sign in with Vipps, the payment app, so it was added as a custom provider in Supabase Auth. One account can have several sign-in methods linked to it.

The Strava integration was built, then shelved.

For a ride and run app, picking one of your Strava routes straight from the event form was an obvious feature. It was built end to end, from connecting the account to a route picker.

It never reached users. Strava’s terms for production use are strict, and after reading them the feature was put on ice. Instead an event takes a plain route link to any planning tool, Strava included.

Notifications are rows first, pushes second.

Notifications started as pushes and nothing else, so a missed one was gone. Now every notification is saved in the database first and pushed after. That gives the app a feed with unread counts, and the feed and the push always match.

The API decides who gets each one, following per-club opt-outs and preferences. Each notification is sent from a background goroutine, so the request that triggered it never waits for the fan-out. Pushes go to Expo in batches of 100. Reminders come from a job that runs every five minutes and sends them between 24 and 2 hours before an event starts.

Coding agents work inside the same guardrails I do.

Every repo has skills for the work that keeps coming back, like adding an endpoint, adding an event attribute, building UI to the design rules or cutting a store release. They point to the domain glossary, the ADRs and the design rules instead of copying them.

Store release notes are written by an LLM at the very end. Each pull request that changes something for users adds a line to a release file. At release time the model turns the lines in that exact build into one short note and leaves out internal changes. Lines merged after the build wait for the next release.

Shipping is automated end to end

  • CI on every pull request

    Every pull request runs type checks, lint and tests in all three repos. The API’s integration tests run against real Postgres in a local Supabase stack.

  • Migrations and releases

    Migrations deploy from GitHub Actions. A script handles app releases, from the EAS build and Fastlane submission to the store notes and a GitHub release for each version.

  • Release notes in Slack

    Each pull request starts with release notes, and a webhook posts them to Slack when it merges. CI checks the format, so a badly formatted description fails before it reaches the channel.

  • Observability

    The app sends its platform, version and build with every request, and the API logs each one with a request ID. The app reports errors to Sentry, and Slack gets an alert for sign-ups, new clubs, merges and store releases.

  • Internal tooling

    An admin dashboard shows growth charts and lists clubs that haven’t posted in 21 days, so I can follow up with them.

  • Decisions and conventions in the repo

    The decisions above are written down as ADRs, and a domain glossary keeps the API, the apps and the docs using the same word for each thing.