
Beerfolio
Website Development
React 19
TypeScript
Vite
Tailwind
Leaflet
Framer Motion
Photoshop
The collection lived in a physical portfolio book at home and on an aging PHP site that was effectively unusable in a grocery store. Finding out whether I already owned a beer meant scrolling a static gallery on a phone; the brewery map was built on Google Maps, which meant an API key, a billing account and a page that counted beers by fetching all 245 brewery pages on every single load. Worst of all, adding a label was a desk job: crop the scan, export three image sizes, name each one by hand, upload them, then edit the data to match. The book always ran months ahead of the site.
A React and TypeScript rebuild driven by two static JSON files. Search, filters, grouping and per-brewery beer counts all happen in memory, so the app makes one request for the data and none after that. The Google Maps layer became Leaflet with OpenStreetMap tiles, with no key and no billing, and it lazy-loads, so the map costs nothing until you open it. Adding a label became a single dialog on the phone that took the photo: crop, three fields, save. The browser does the cropping, the resizing and the file naming.
1,355
Beer labels
336
Breweries mapped
27
Countries
102
Beer styles

Labels render as a responsive grid with infinite scroll and WebP thumbnails at 1x and 2x. Each card carries the brewery, style and the year the label joined the collection, the three things I actually sort by.

Beerfolio began as an expandable portfolio book on a shelf: peel the label off the bottle, press it flat, slide it into a sleeve. The book is a great conversation piece and it is why guests keep contributing labels, but it has one flaw: it stays home. The site exists to bring the book to the store, and every label in it is a scan of the physical page.

The whole app is judged on one interaction: I am holding a bottle, and I need to know in a couple of seconds whether it is already in the book. Search runs across name, brewery, style and year at once, and the brewery, style and year dropdowns re-scope themselves to whatever is left. Searching 'stout' drops 1,355 labels to 157 across 75 breweries. Because everything is already in memory, results land as fast as I can type.

Every label in the collection is three image files, not one: the full-size scan, a WebP thumbnail, and a 2x WebP for retina screens. That is why 1,355 labels come to exactly 4,065 images. Producing that set by hand meant cropping to 3:4, exporting three sizes and naming all three brewery-year-title so nothing collides, which was the real reason labels piled up unadded for months. Now the whole set comes out of one dialog: drop in a photo and the browser generates both thumbnails on a canvas at 220px and 495px wide.

Two details do most of the work here. The crop is a live 3:4 preview you drag or nudge with a slider, so framing a label is a two-second gesture instead of a round trip through an image editor. The bias is stored on the label, so it can be re-cropped later without the original ever being touched. The brewery, title and style fields autocomplete from what is already in the collection, which sounds cosmetic and is not: brewery name is the join key to the map, so typing 'Sierra Nevada Brewing' instead of 'Sierra Nevada' would silently drop a pin off the map.

Filenames are derived from brewery, year and title, so correcting a typo used to mean renaming three files and repointing the record at them, which is exactly why typos stayed. The editor now works out what actually changed: adjust only the crop and it regenerates the two thumbnails and leaves the original alone; change the title, brewery or year and it re-uploads under the corrected filename so the file and the record can never drift apart. One multipart POST goes up, labels.json is rewritten about 700ms later, and there is no publish step at all.

The old brewery map was Google Maps, which meant an API key and a billing account for a personal side project. I ported it to Leaflet over OpenStreetMap and CartoDB dark tiles, added marker clustering so 336 pins across 27 countries stay readable at world zoom, and lazy-loaded the whole thing with React.lazy. Leaflet only downloads when you open the map, which keeps the initial bundle at roughly 356 KB instead of 548 KB.

Brewery name is the join key between the two JSON files, and that single decision is the architecture. Beer counts per brewery are grouped out of the label data at runtime, so the map needs no extra data and makes no extra requests. Sierra Nevada's 92 is computed, not stored. The old site's 245 requests per page load are simply gone. Markers render each brewery's logo as a CSS background image, and clicking 'View labels' filters the grid to that brewery and drops you back into it.