Most nutrition tools treat food as a fixed item: one product, one label. Coffee drinks break that model immediately. A single latte can be ordered in multiple sizes, with a handful of milk options, any number of syrup pumps, and optional toppings. That's a combinatorial explosion if you try to store every variant.
This post walks through the approach we used for a coffee-drink nutrition calculator and the trade-offs behind it.
Don't store variants, store a base plus deltas
Instead of a row per combination, each drink gets a base recipe for a reference size and default milk. Every customization is then a delta applied on top:
type Nutrients = { calories: number; fat: number; carbs: number; sugar: number; protein: number; caffeine: number };
interface DrinkBase {
slug: string;
sizes: Record<SizeId, Nutrients>; // published values per size
defaultMilk: MilkId | null;
defaultPumps: Record<SizeId, number>;
}
interface Modifier {
id: string;
perUnit: Nutrients; // e.g. one syrup pump, one topping serving
}
Using published per-size values for the base (rather than scaling one size mathematically) matters. Cup sizes don't scale linearly in practice: ice displaces liquid in cold drinks, espresso shots step up in jumps, and some sizes get extra pumps.
Milk is a substitution, not an addition
Syrups and toppings are additive: add n * perUnit. Milk is different. Swapping 2% for almond means removing the default milk's contribution and adding the new one. So each drink also stores an approximate milk volume per size, and the swap becomes:
total = base - milkNutrients(defaultMilk, vol) + milkNutrients(selectedMilk, vol);
This keeps the math honest for drinks where milk dominates (lattes, cappuccinos) and nearly irrelevant for drinks where it doesn't (Americanos, brewed coffee).
Keep the UI reactive and the data boring
The calculator UI is just four controls: drink, size, milk, and a list of syrups and toppings with quantity steppers. Each change recomputes the total in a pure function, so there's no server round trip, and the result can be cached and shared via URL params.
The data layer is deliberately boring: static JSON generated from published nutrition information, versioned in the repo, with a changelog entry whenever values are updated. Static pages per drink also mean every drink has a crawlable, fast detail page, which helps both users and search engines.
Edge cases worth handling
- Sugar-free syrups need their own modifier entries rather than a "sugar = 0" flag, since they still carry small amounts of other nutrients.
- Cold foam and sweet cream are toppings with real volume; treat them as modifiers with a serving size.
- Clamp inputs. Negative pumps or 40 pumps of caramel shouldn't silently produce nonsense.
See it running
If you want to poke at a live implementation of this model, Starbucks Calorie Calculators is a working example of this kind of configurable model: pick a drink, change the size or milk, add pumps, and watch the totals update instantly.
The broader lesson applies to any configurable product, from pizza builders to meal kits: model the base, model the deltas, and let a pure function do the rest.
Top comments (0)