Beerbelly

Idea

I like beer, but I didn’t like the rating system on Untappd. I wanted something more like Beli. So my roommate and I created Beerbelly, an app that helps you rate beers you’ve had relative to other beers you have, making the ratings unique to you.

With your unique ratings, Beerbelly can surface your favorite beer styles and show trends between rating and attributes such as ABV or IBU.

Beerbelly ranked beer list Beerbelly statistics: most frequent styles and IBU versus rating

You can also see what your friends are drinking and how they’ve rated it.

Beerbelly user list showing each drinker’s average, rating count and favorite

Features and technical info

You never enter a number. You drop a beer into good, fine, or bad, then answer a few head-to-head matchups against beers you’ve already rated. The client runs a binary search over your existing ranking and uses you as the comparison function, so each answer halves the remaining window and placing a beer among a hundred costs about seven taps.

Beerbelly head-to-head matchup: which do you prefer?

The obvious way to store an order is a position column on each row, but inserting at the top of a 200-beer list then means rewriting 200 rows, and every insert races every other insert. Instead each user’s ranking is a doubly linked list in SQLite: a rating points at the rating above it and the one below it, and no row knows its own rank. Splicing a beer in is three writes whether the list holds five beers or five hundred, and deleting is the mirror image. Reading it back is a recursive query that anchors on the head and hops down the chain, joining out to beers and breweries in the same round trip.

The 0–10 scale is cut into three bands. Good owns 10 to 6.67, fine 6.67 to 3.33, and bad 3.33 to 0. Each band is divided evenly among however many beers live in it. Rating a new beer re-scores its neighbors, so no two beers have the same score.

Most of the real work is in the edge cases. Land at the top of a band and there is no beer above you within it, so the client reaches across to the last beer of the band above. Rate your first ever fine beer and the band is empty entirely, so it scans outward through the global list for the nearest better and worse beers to splice between. A single request carries the beer, the band and the two neighbor ids; the server validates that both still exist before re-pointing them.

The API is described exactly once, as annotations on the Rust handlers. An OpenAPI spec is generated from those, and the TypeScript types, clients and data-fetching hooks are generated from the spec. One layer down, queries go through sqlx macros that check the SQL against a real schema at compile time.

Rust on Axum and Tokio, SQLite with an FTS5 virtual table for search, React and TypeScript on Vite, SWR for fetching and cache invalidation. In production the Axum server is also the web server. It serves the built app as its fallback route, so the API and the app share an origin and there is no CORS layer and no split deploy. A Nix flake builds the binary and the frontend and combines them, so the whole thing installs as a single unit.