All writing
11 May 2026

Building a pantry app, and the strange problem of naming an egg

I throw away food. Not in a dramatic way — no rotting fridge, no weekly skip-load — but a pack of bacon I forgot about, a bag of spinach that turned, the last quarter of a block of cheese that quietly got too hard to use. Small amounts, repeatedly. The kind of waste that doesn’t shame you in a single moment but compounds across a year into something you’d rather not think about.

So I spent a weekend building a pantry app for my phone. A small one. Four screens, three files, one Supabase project on the free tier. It works, I use it every day, and the most interesting part of building it had almost nothing to do with code.

What I actually wanted

I tried to be precise with myself about what the app was for. Not because I wanted to write a product brief — because the easiest way to build something useless is to start adding features before you’ve decided what question the thing is answering.

I wanted the app to answer one question, well: what should I cook tonight, given what’s in the fridge?

Everything else — the inventory, the use-by dates, the shopping list — exists in service of that question. A pantry app that only tracks inventory is a spreadsheet. A pantry app that tells you, at 6:45pm on a Wednesday, that you should make spaghetti carbonara tonight because the eggs go off tomorrow, is something I’ll actually open.

I also wrote down what I wasn’t building: no calorie tracking, no recipe discovery, no social features, no accounts, no native app, no tutorial. Each of those was a real temptation. Each would have made the project bigger and the use case worse.

The shape of it

Four screens, in the order they appear when you open the app:

Plan. Seven days, each one with a button to drop a recipe onto it. Tonight, tomorrow, then the weekdays by name.

List. A shopping list derived from the plan minus what’s in the pantry. Nothing to maintain — it just computes itself.

Pantry. Every ingredient with a use-by date and a four-pixel coloured bar on the left of each row. Red for today, amber for this week, green for fine, a darker red for already expired. You can scan the whole pantry in two seconds.

Recipes. My twenty or so regular meals, each tagged “ready to cook” if everything’s in stock, or “3 missing” if it isn’t.

The recipes screen and the pantry screen know about each other. That’s where the work was.

Adding a recipe to tomorrow's meal slot.
Tonight's meal is already planned. Add tomorrow's by tapping a recipe — the planner accepts it without ceremony.

The stack, briefly

The whole thing is a Progressive Web App. You open it in Safari, tap “add to home screen”, and from that moment on it behaves like a native app — opens fullscreen, has its own icon, works offline. I didn’t write a line of Swift. iOS PWAs have improved enough in the last couple of years that for an app of this shape, native is no longer the right answer.

The frontend is React, loaded from a CDN, transformed in the browser by Babel Standalone. No build step. No node_modules. The whole repository is index.html, manifest.json, sw.js, and a seed recipes.json. You can read the entire app top to bottom in a single sitting.

The backend is Supabase. About a hundred and fifty lines of hand-rolled fetch() calls against the auto-generated REST API. No SDK. The data is obviously relational — items, recipes, ingredients, meal plan entries — so Postgres was the right shape from the start.

A service worker caches the app shell and the CDN scripts. Wifi is unreliable in kitchens. That alone made offline support a real requirement rather than a nice-to-have.

I considered React Native, Firebase, building my own barcode scanner, and an LLM-backed ingredient parser. I killed all of them. The cost of each — tooling, deployment, latency, monthly bill, complexity — was disproportionate to the value it would have added.

The problem that turned out to matter

I want to spend most of this post on the part I didn’t expect to spend most of my time on.

The app supports barcode scanning. You point your phone at a packet, the camera reads it (using the ZXing library), and the app looks the code up against the Open Food Facts database — a free, crowdsourced database of around a million products. The product name comes back, and the app uses it to pre-fill the “what is it?” field.

The problem is that Open Food Facts returns something like:

Sainsbury’s Woodland Free Range British Large Eggs, 6 Pack

And a recipe says:

6 eggs

The app needs to know these are the same thing. Otherwise the pantry says “you have Sainsbury’s Woodland Free Range British Large Eggs,” the recipe says “you’re missing eggs,” and the whole thing collapses into uselessness on day one.

This is, on the surface, a string-matching problem. In practice none of the obvious approaches work:

  • Direct string matching is brittle. “Free Range” trips it. Plural-versus-singular trips it. Brand names trip it.
  • A hard-coded dictionary explodes in maintenance the first time you buy a new brand of pasta.
  • Calling an LLM every time you scan a barcode works fine for one user with patience and a credit card, but adds latency, cost, and a network dependency to the single most-used interaction in the app.

What I ended up building is a two-layer model, with one small UX trick that does most of the work.

A product is whatever the barcode said. “Sainsbury’s Woodland Free Range British Large Eggs.” That string is preserved verbatim, because tomorrow when I look at the pantry I want to see what’s actually in there, not a sanitised version of it.

An ingredient is the canonical name a recipe uses. “Eggs.” Lowercase, singular where possible, no brand.

The user — me — maps the first to the second, once, the first time I scan it. That mapping is stored in Supabase against my household, and reused forever.

The UX trick is this: when you scan a new product, the app strips the obvious noise words from the product name — free, range, british, large, organic, pack, about fifty more — and surfaces the remaining words as one-tap chips. So instead of typing “eggs” into a search box, you see “eggs” as a button. Tap it. The app searches the ingredients you’ve already used in your recipes. Tap the match. Done.

Total interaction, after the scan: two taps.

Scanning a new product and linking it to a recipe ingredient.
Scan, accept the smart defaults, pick a use-by date — then "Eggs" appears as a chip and matches three recipes that use eggs. Two taps to link "Tesco British Free Range Large Eggs" to the canonical ingredient.

What I notice using it is that the user never wants to do data entry. They want to do their actual task — putting eggs in the fridge — and have the data entry happen as a side effect. The chip-tap is the smallest possible footprint a user can leave that produces the structured data the app needs.

The stopword list is unfashionable as a technique. It’s the kind of thing you’d be embarrassed to mention in a design review at a company that has a machine-learning team. For this problem, with one human already engaged in the loop, it’s exactly the right tool.

Smaller details that earn their place

A few things I’m quietly proud of.

The quantity field has smart defaults based on the item name. If the name contains “milk”, the unit defaults to ml and the quantity to 500. If it contains “egg”, the unit defaults to “whole” and the quantity to 6. About thirty lines of code, covering most household items. It’s the difference between scanning a pack of eggs and pressing Save, versus scanning, then typing 6, then choosing “whole” from a dropdown, then pressing Save.

The use-by field doesn’t have a date picker. It has presets: Today, Tomorrow, In 2 days, In 3 days, In 4 days, Custom. In a kitchen, nobody wants to tap through a calendar. Five buttons covers most check-ins.

The pantry filter chips only appear when they have something to say. If you have no expired items, the “Expired” chip doesn’t render. The empty state is the default state.

Filtering the pantry by 'Use soon' and 'Expired'.
Tapping "Use soon" filters to the five items getting close. The coloured bars on the left tell you which are red and which are amber without you having to read anything.

The shopping list is a derivative, not a list. It’s computed from (planned meals × ingredient quantities) − (current pantry stock) every time you open the screen. There is nothing to maintain. The list cannot get out of sync, because the list isn’t stored anywhere.

None of these are clever. They’re all small bets on the assumption that the user is tired, holding a baby in one hand, and trying to put a pack of mince in the freezer before it defrosts.

What’s next

The current version is fine for me. There are a few things I’d build next.

Multi-household sync. Every install currently shares one hard-coded household identifier. The schema already supports multi-tenancy — household_id is on every table — but there’s no auth flow. Adding Supabase’s magic-link auth is probably an afternoon’s work.

A “cook tonight” screen. The data is all there: the meal planner already computes which recipes you can cook right now, and ranks them by the expiry urgency of their ingredients. I just haven’t given that ranking its own screen yet. It’s a sort, not a feature.

OCR on use-by dates. Photograph the date on the package, read it off automatically. Possible with TensorFlow.js. I’m not sure it’s worth it — manual entry through the date presets is four taps, and OCR would still need a confirmation tap. Diminishing returns.

The shape of the thing is right, though. Four screens, three files, one question.

What should I cook tonight?