Studio Smith-Cordell
Studio Smith-Cordell
Laptop and smartphone displaying a responsive, filterable digital restaurant menu and dietary filter drawer set against a dark concrete background.

From PDF to Postgres: Rebuilding the Restaurant Menu

Building a filterable menu sounds like a UI problem. It isn't. The hard part is the schema underneath it, and getting that wrong doesn't just make the site clunky — it can put a customer in hospital. Here's how the model has evolved across several years of building these for hospitality clients, and why the boring part (an enum, a join table) matters more than the buttons on the page.

PDFs are a bad way to serve a menu. Anyone who’s tried to read one on a phone knows the drill: pinch, zoom, scroll sideways, lose your place, zoom out, try again. It’s a UX problem we’ve been fixing for hospitality clients for years — but the more interesting problem, the one that actually matters if you get it wrong, is dietary data.

A menu isn’t just a list of dishes and prices. For a lot of customers it’s the thing they rely on to work out whether they can eat somewhere at all. Allergen and dietary information needs to be clear, current, and easy to find — and a laminated chart printed six months ago, or a PDF nobody’s updated since the menu changed, makes that much harder than it needs to be. That constraint has shaped how we build these systems far more than anything to do with layout or animation.

It’s worth saying upfront why this problem is worth solving at all, beyond the obvious duty of care. Dietary requirements are common enough that ignoring them is a commercial mistake, not just an ethical one. Coeliacs, nut allergy sufferers, vegans, vegetarians, people avoiding spice for medical or personal reasons — a filterable menu turns “can I even eat here” into a five-second answer instead of a phone call to the venue or an awkward conversation with a waiter. For the venue, that’s fewer support calls, fewer walk-outs, and clearer, more current information than a static document can offer.

Version one: a binary that wasn’t enough.

My first attempt at this was years ago, on Webflow. Every menu item had a required field for each of the fourteen major allergens: contains, or does not contain. It worked, in the sense that it produced a filterable menu. But it had two problems that only became obvious with real use.

The first was maintenance. Every single item needed an explicit yes/no for every allergen, whether or not that allergen was remotely relevant. Adding a new dish meant working through fourteen fields one at a time, even for something as simple as a black coffee. Multiply that by a menu with eighty items and a chef who wants to swap two dishes a week, and the system quietly encourages shortcuts — which is exactly what you don’t want in data customers rely on.

The second was that “contains / does not contain” is a false binary. Kitchens have shared fryers, shared prep surfaces, and dishes that can be made without an allergen if someone asks. None of that fits into two states. A dish that “may contain traces of nuts due to shared equipment” is a materially different claim from “does not contain nuts”, and collapsing them into the same answer loses information a customer might actually want.

There’s a subtler failure mode too, which only shows up once a client has been using the system for a while: a required field that’s usually “no” trains people to click through it without reading it. If ninety percent of your allergen answers for a given dish are “does not contain,” the person entering the data stops treating each field as a meaningful decision and starts treating it as a box to clear. That’s exactly the kind of drift you don’t want in information customers rely on when choosing what to order. A model that only asks about what’s relevant doesn’t have that failure mode, because there’s nothing to click through — you either add a tag or you don’t.

Version two: a proper data model.

The current version fixes both problems with a schema that only records what’s actually true, and a three-state enum instead of a binary:

  • Menu items — the dishes themselves
  • Dietary tags — allergens, plus vegetarian, vegan, and (for kitchens that serve spicy food) a spice flag
  • A join table between the two, carrying a status enum: contains, may_contain, removable

Rather than every item needing fourteen explicit fields, you only add a row when there’s something to say. A black coffee gets no allergen rows at all. A curry gets contains: gluten, may_contain: nuts, removable: dairy if it can be made without the yoghurt on request. Adding a dish is now “tag what’s relevant,” not “answer fourteen questions” — which means it’s far more likely to actually be kept up to date by whoever’s entering the menu.

The three-state enum matters because it maps onto real decisions a customer makes when reading the menu — it’s what drives the small symbols shown against each dish, and the same values populate the allergen chart. contains gets one symbol, may_contain another, removable a third, so a customer glancing at a dish gets the nuance without having to ask.

Filtering is a separate, deliberately blunter piece of logic. When someone filters out an allergen, anything carrying that allergen in any state — contains, may_contain, or removable — is excluded. The enum informs what’s displayed; it doesn’t get consulted when deciding what to hide. That’s on purpose: a filter is a safety mechanism, and a mistake there should always fail toward hiding a dish rather than showing one that turns out to be unsuitable. The nuance is for a human reading the symbols and making their own judgement call; the filter itself doesn’t try to be clever about degrees of risk.

Take a real example: a Caesar salad. It contains gluten (croutons), contains dairy (parmesan, dressing), and may contain egg depending on how the dressing’s made. The anchovies are removable on request. On the menu, that’s three distinct symbols next to the dish, telling a customer exactly where they stand with each one. Filter for gluten-free or dairy-free, though, and it disappears from the list entirely, same as it would for someone filtering nuts if nuts were merely “may contain” — the filter doesn’t try to distinguish “definitely” from “possibly,” it just removes anything with any degree of risk. Under the old binary model, none of that nuance existed at all; every field was just yes or no, with nothing to tell the customer why a dish had been ruled out or whether asking staff might change the answer.

Vegetarian and vegan don’t need the same three-state treatment, so they’re kept simple: a boolean on the item itself rather than a row in the join table. There’s no meaningful “may be vegetarian” the way there’s a meaningful “may contain nuts” — a dish either fits the diet or it doesn’t, so a flag is enough and the extra structure would just add complexity without buying anything. The rule of thumb that fell out of this: use the tri-state enum where the real-world answer genuinely has more than two states, and don’t force it onto data that doesn’t need it.

One more change I’m currently working through: previously each food menu had its own table, which meant a dish served on both the lunch and dinner menu had to be entered twice — two places for the data to drift apart. That duplication doesn’t just cost time re-entering a dish; it means the dietary tags for “the same” dish can end up subtly different between menus if one gets updated and the other doesn’t. A kitchen fixes the dressing recipe on the lunch menu to remove egg, forgets the dinner menu has the identical dish under a different row, and now the site is telling two different stories about the same plate of food.

I’m moving to a model where menu items exist independently, menus are their own entity, and a join table links items to whichever menus they appear on. Same dish, one row, referenced wherever it’s needed. A venue with a lunch menu, dinner menu, and a set menu that shares six dishes with the other two now enters those six dishes once and links them three times, rather than maintaining three separate copies of the truth. It’s a small change on paper but it removes an entire category of “the allergen info doesn’t match between the two menus” bugs before they can happen, and it’s the kind of thing that only becomes obvious once you’ve watched real venues struggle to keep multiple menus in sync by hand.

One dataset, two legally required outputs.

In the UK, hospitality businesses are legally required to display allergen information for customers — traditionally a printed chart, often laminated, frequently out of date the moment a dish changes. Because the allergen data already exists as structured rows against the same menu items, generating that chart is close to free: it’s a different view over the same source of truth, not a separate document someone has to remember to update.

That’s the part of this I think is actually worth paying attention to, more than the filter UI itself. The interesting engineering problem isn’t “let a customer tick a box” — it’s making sure there is exactly one place the allergen data lives, so the online menu, the filtered view, and the legally required chart can never quietly disagree with each other. A PDF chart printed in January and a menu that changed in March is a compliance risk as well as a UX one. Deriving both outputs from one schema removes the drift entirely.

Making the filtering feel instant.

None of the above matters if the filtering itself feels sluggish, so the client-side side of this is built to be fast:

  • Menu content is cached in Cloudflare KV, so it’s served to the client quickly rather than round-tripping to Postgres on every page load.
  • Menu items render in a keyed {#each} block, and filtering is done client-side with Svelte 5 runes — toggling a dietary requirement re-evaluates the list without a network request.
  • Because venues often have more than one menu (food, drinks, brunch), the customer’s selected dietary preferences are held in shared state and persist as they move between menu pages, rather than resetting every time.

The combination means a customer can tick “vegetarian, no nuts” once and have it apply everywhere on the site, with the list updating instantly as they adjust it. The filtering itself is a $derived.by that builds a map of active allergen filters from state, then checks each menu item against it:

const filteredMenu = $derived.by(() => {
	const filters = {
		gluten: dietary.glutenFree,
		dairy: dietary.dairyFree,
		nuts: dietary.nutFree,
		// ...remaining allergens
	};

	function filterMenuItems(items: FoodMenuItemWithAllergens[]) {
		return items.filter((item) => {
			for (const [key, value] of Object.entries(filters)) {
				if (
					value === true &&
					item.food_menu_allergens.some((allergen) => allergen.allergens.name === key)
				)
					return false;
			}
			return true;
		});
	}

	if (dietary.vegan) return filterMenuItems(veganItems);
	if (dietary.vegetarian) return filterMenuItems(vegetarianItems);
	return filterMenuItems(menu);
});

Vegan and vegetarian are checked first against their own boolean-filtered lists, then the allergen filters run on top — keeping the two concerns separate rather than folding everything into one combined condition.

What I’d still improve.

I don’t think this is finished. A few things I’m actively thinking about:

  • The filtering logic above checks whether an allergen row exists at all, not what its status is — so a removable allergen currently excludes an item from a filtered view in exactly the same way a contains one would. A dish where nuts can be left out on request should probably still show up for someone filtering nuts out, with a note that it needs a modification. That’s the next thing to fix, and it’s a good example of the schema already being ahead of the UI that reads from it.
  • Surfacing may_contain more clearly in the UI than a small icon — it’s the state most likely to be misread at a glance.
  • Making the menu/item split above easier to migrate existing clients onto without re-entering their data by hand.

The unglamorous parts of this — the schema, the enum, the single source of truth — matter a lot more than the filter buttons on the page. The UI is the easy bit once the data underneath it is structured properly.