How it works
Four steps run between pasting a recipe and seeing a number. Each one can be wrong in a way you can see and correct, which is the point — nothing here is a black box.
1. Reading the recipe
There are four ways to hand a recipe over, named as tabs above the box rather than hidden behind a menu: paste the text, give the address of a page that publishes one, photograph the page it is printed on, or photograph a packet.
The middle two are worth separating. Importing by address reads the schema.org Recipe block a page publishes for machines — the ingredient list, the title and the yield — and never fetches the method, which is where the writing is and which stays on the page that published it behind a link back. Photographing a recipe is for anything on paper: a cookbook page, a card in a tin, a page out of a magazine, none of which can be pasted and none of which have an address. Only the ingredient list is transcribed there too, verbatim — no converting, no tidying, no calculating — and any quantity that could not be read clearly is named rather than guessed at, because a blurred figure produces a number that looks exactly as confident as a correct one.
Before any page is fetched, the site’s robots.txt is read and obeyed. That
makes importing work in slightly fewer places, which is the point: a site
that has written down which paths it does not want fetched has said so in the
one place everybody agreed to look. There is also a blocklist, so a site that
writes and asks to be left alone is a settings change rather than a release.
Some sites go further and will not serve an automated request at all — their bot protection answers 403 to anything that is not a person in a browser. That is their call to make and there is no header worth changing to get around it: dressing the fetch up as a browser would only defeat a control somebody put there on purpose.
So a refusal is reported as a refusal rather than as a fault, and it opens the one route that genuinely works — reading the page in your own browser. A site that will not serve a server has not refused you: the page is open in front of you. Save it and drop the file in, or paste its source, and the same schema.org data is read here in the tab. No request is made, nothing is uploaded, and the recipe reaches the results page through the URL fragment like every other one — which browsers never send to a server.
All of them end up as the same text, so everything below this point is one code path regardless of how the recipe arrived.
Two parsers then run, in that order.
A rule-based parser runs first, in your browser. It splits the text into
lines and tests each against a set of rules — no network, no cost, under a
millisecond. It handles fractions (1 1/2, 1½), ranges (2-3 cloves, taken
as the midpoint), package sizes (1 (400g) tin), size words (1 large onion)
and bullet characters, and skips anything that reads like a method step. Your
ingredient list appears immediately from this pass.
Most of what it does is throw things away. A recipe pasted off a blog brings the whole card with it — a star rating, the author, four kinds of time, a course, a cuisine, a row of Print/Pin buttons and a nutrition panel that is itself a list of numbers. Every one of those parses as plausibly as an ingredient does, so they are matched and dropped by name. Tick boxes and bullets are stripped as a run, because “* ▢1 lb. pasta” has two of them and removing only the first leaves the quantity glued to a symbol.
If the text has no title, the ingredients usually say what the dish is: pasta with a cheese sauce is macaroni cheese, and eggs with cream and sugar and no flour is a custard. A name is suggested rather than assumed — it is labelled as a guess, and one click opens an editor with alternatives to pick from.
Then a language model reads the same text, and usually replaces that first guess. It handles the lines rules miss — unusual phrasings, ingredients written mid-sentence, sub-recipes — but its real job is the next step’s problem: translating what you wrote into the vocabulary the nutrition database uses.
The model runs on the server, once per distinct recipe, and the result is cached by a hash of the text. Analysing a popular recipe costs nothing after the first person does it. If the model is unavailable for any reason, the rule-based parse is what you keep — the site never breaks, it just matches slightly less well.
2. Matching each ingredient to a food
USDA descriptions are not written the way recipes are. The database calls plain flour “Flour, wheat, all-purpose, enriched, bleached” and an onion “Onions, raw”. Searching it with the words off a recipe card misses constantly.
So each ingredient gets a second, machine-readable name in that idiom:
| You wrote | Searched as |
|---|---|
2 cups plain flour, sifted |
flour, wheat, all-purpose |
2 x 400g tins chopped tomatoes |
tomatoes, canned |
1 large free-range egg |
egg, whole, raw |
500g lean beef mince |
beef, ground, lean, raw |
a splash of good olive oil |
oil, olive |
The rule is that qualifiers which change the nutrition are kept — canned, frozen, dried, raw, whole wheat, lean, skinless — and qualifiers which don’t are dropped: brand names, “organic”, “free-range”, and preparation words like chopped or grated. The list you see keeps your wording; only the search uses the translated form.
Anything that still can’t be matched is flagged Not matched in amber rather than silently guessed at, and you can pick the right food from a list — or photograph the packet. Scanning a label is offered right on the row, because that is the moment it earns its keep: a generic database entry is an average, and for anything branded the packet in your hand can be a long way off it. Sodium is the worst offender — two brands of the same product can be threefold apart. A scanned label becomes that ingredient’s numbers outright, with no name lookup in between.
3. Converting to grams
USDA nutrient data is per 100 grams, so everything has to become a weight. Three different routes get there:
- Weights convert exactly.
8 ozis always 226.8 g. A weight never goes through a per-food lookup — an ounce is 28.3495 g of anything, and a lookup could only add a way to be wrong. - Measures the database weighed itself are used before anything is derived. FoodData Central publishes a gram weight per cup, per tablespoon, per teaspoon, per slice and per item for most foods, all measured. A tablespoon is a sixteenth of a cup by definition, but a spoon of flour and a levelled sixteenth of a cup of flour are not the same amount of flour — so where FDC weighed the spoon, the spoon is what gets used.
- Volumes otherwise convert via the food’s own density. This matters more than people expect: a cup of flour is 125 g, a cup of sugar is 200 g, a cup of honey is 339 g. Using a single density for everything would be wrong by a factor of nearly three at the extremes.
- Counts convert via a typical item weight — one clove of garlic is 3 g, one medium egg is 50 g, one medium onion is 110 g.
Where none of those routes works — an unrecognised unit, or a food with no per-item weight on record — the weight is estimated from the kind of food it is. That matters more than it sounds. An earlier version assumed 100 g per item for everything, which turned “2 heaping tsp. dry mustard” into 200 grams of mustard: a plausible-looking number twenty times too large, sitting next to a pound of cheese that was right to the gram. A spice arriving as a bare count is almost always a spoon whose unit failed to parse, so the assumption is spoon-sized. Being too small is the safer error.
Any row whose weight was estimated rather than converted is tagged est. weight, so you know which numbers to trust least.
Every weight is shown in both systems — 454 g / 1 lb, 9 g / 0.32 oz — for
the same reason the ingredient list is editable: the only way to check a number
on this page is to weigh the thing, and a kitchen scale has a metric mode and
an imperial mode with nothing in between. Under an ounce the imperial figure
gets two decimal places rather than being rounded away, because “0.07 oz” is
the honest way to say that an imperial scale cannot weigh a teaspoon of
mustard.
Volume measures are US customary, which is what US recipes and FoodData
Central both assume. A US cup is 236.6 ml and a US tablespoon is 14.8 ml — if
your recipe is Australian, its tablespoon is 20 ml and you should say 20 ml
rather than 1 tbsp.
4. Totalling and dividing
Every ingredient’s per-100 g nutrient vector is scaled by its weight and summed. The total is then divided by the serving count you set.
The macro split percentages are calculated from the macro grams using Atwater factors (4 kcal/g protein, 4 kcal/g carbohydrate, 9 kcal/g fat) so the three slices always add to 100%. This can differ by a few percent from the stated calorie figure, because USDA calories come from measured food energy rather than from multiplying the macros out. That gap is real, not a rounding bug.
All of this is ordinary arithmetic, deliberately. The model reads text; it never computes a nutrition figure. Every number on the page comes from multiplying a database value by a weight, which means it is reproducible, testable, and cannot be hallucinated.
5. Working out what would improve it
The results page offers a goal — cut saturated fat, cut added sugar, cut salt, raise protein, raise fibre — only when the recipe actually has that problem. A wall of buttons that are irrelevant to what you pasted teaches people to ignore all of them.
Within a goal, the suggestions are ranked by which ingredient is responsible. “Reduce fat” is useless advice; “the 250 g of butter is 78% of the saturated fat, and oil in its place would cut 14 g per serving” is something you can act on. So each option names the row it changes and is sorted by how much that row contributes.
The number attached to every option is the computed result of making that change — the whole recipe re-totalled with the substitution in place, using the same code as the main figures. Nothing is estimated, and the prediction cannot disagree with what you see afterwards. Options that would move the number by less than 5% of where you already are, or by less than a fixed floor, are not shown at all: a change smaller than the error in the estimate is filler, not advice.
Where the honest answer is not a substitution, it says so. Cutting a quarter of the sugar in a bake usually beats every sweetener swap available, and the panel offers that as its own option rather than pretending a swap is always the answer. None of this involves a model call, so it works when Claude does not.
Which suggestions appear at all depends on the dish. A substitution table knows that parmesan and cream cheese are both cheese; it does not know that the 30 g of parmesan in a bolognese is seasoning four portions of sauce, and that a soft fresh cheese cannot do that job. It does not know that the cream in a crème brûlée has to set with the egg, and that Greek yogurt will curdle instead. Both are perfectly good swaps somewhere else, which is exactly why the table offers them.
So the dish is guessed — from the title first, then from the composition — along with what each ingredient is doing in it: holding the thing together, bulking it out, enriching it, or seasoning it. Suggestions the pair rules out are removed and the reason is shown, because “a soft fresh cheese would disappear into this” is more useful than a shorter list. An unrecognised dish blocks nothing: a wrong block costs a good idea, which is worse than an extra mediocre one.
Hovering any substitution shows what it does to the dish rather than to the numbers. That advice is a function of both foods, not just the one you are switching to: sugar → honey means giving up creaming, holding back other liquid and dropping the oven, while maple syrup → honey means almost nothing at all. Half of it is derived from the two nutrient vectors — a moisture difference is arithmetic — and half from a small table of things no nutrient vector records, like whether a fat is solid at room temperature or a flour has gluten.
6. Rescaling
The Recipe Rescaler shares the parser and the food table and asks a different question. Any line can be the pivot, because the constraint in a real kitchen is usually an ingredient rather than a serving count, and the factor is derived in grams so the old and new amounts need not even share a unit.
Converting between cups and grams is per-ingredient by necessity: a cup of flour is 125 g and a cup of sugar is 200 g, so there is no single factor to apply. The per-cup weight of each matched food comes from the same USDA data as everything else, and an ingredient that could not be matched simply keeps the unit the recipe gave it rather than being converted through a guess.
Every amount is then rounded to something the kitchen can actually produce. There is no 3/8 cup measure, so cup fractions snap to the ladder that exists — eighth, quarter, third, half, two thirds, three quarters. Metric never grows a fraction, because a fraction of a gram is a rounding error with a slash in it. And small spoon measures survive every conversion untouched, because metric recipes say “1 tsp vanilla” too, and nobody can pour 3 ml of anything.
7. Converting without scaling
The Recipe Converter is the rescaler with the scaling taken out — the factor is pinned at 1, and the only control is which system to write the result in. Every quantity is the same amount of food as the one it replaced.
It is a separate page rather than a mode of the rescaler because it answers a different question. Somebody holding an American recipe and a metric kitchen does not want to be asked how many servings they need first; they want the list rewritten and nothing else touched, and routing them through a scaling tool means explaining on arrival which controls to ignore.
“As written” is not offered there. On the rescaler it means “change the amounts, keep my units”, which is a real thing to want. On a converter it means convert nothing, which is the page doing nothing at all — a control whose job is to make the tool inert is not a choice.
Correcting it
Two different things can be wrong, and they are handled very differently on purpose.
A wrong match — “you matched plain flour to cake flour” — is cheap to be wrong about and cheap to undo, and the reader is looking at both the food they meant and the one they got. So this one acts on its own: once three different people report the same mapping, its confidence drops to zero and it goes stale, and the next lookup resolves it from scratch. Nothing is deleted. If the reporters were wrong it resolves to the same food again and the cost was one extra request.
A wrong number only ever queues. Every figure on this site is a database value multiplied by a weight, so letting anonymous reports edit nutrient data would make the database say whatever the loudest visitor believes — and it would do it silently, because wrong numbers look exactly as confident as right ones. USDA is the authority; a report against it is a prompt for a person to go and check the source, not a vote. The interface says which of the two just happened, because telling somebody “thanks, fixed” when nothing was fixed is worse than telling them nothing.
Where the data comes from
Nutrient values come from USDA FoodData Central, the US government’s food composition database — the same source most nutrition labelling is built on.
That mirror is also a public API. /api/convert?food=plain+flour returns the
food’s per-100 g nutrients and the gram weight of a cup, a tablespoon, a
teaspoon, a slice and an item — every measure USDA weighed for that food — plus
measured, which names the ones that came straight off FDC rather than out of
arithmetic. Add &amount=2&unit=cup and it converts that amount and scales the
nutrients to it. A whole ingredient list goes in one POST.
It never estimates. This page will fall back to a category default and badge
the row est. weight, because a rough number a reader can see is caveated
beats a blank; JSON has no badge, and a guess served over HTTP ends up in
somebody’s spreadsheet as a fact. Where a weight cannot be computed honestly
the endpoint returns null and says why.
Rather than call it on every lookup, this site keeps its own mirror. Each food
is fetched once and stored; after that, lookups are answered locally in a
millisecond with no rate limit. Stored records are revalidated against USDA
every 90 days, and a stale record is refreshed behind your response rather
than in front of it, so you never wait for it. You can see the state of the
mirror — how many foods, how many are stale, what share of lookups avoided a
USDA call — at /api/stats.
The site also ships a built-in table of about 140 common ingredients so it works with no configuration at all. Those are rounded reference figures derived from USDA Standard Reference — fine for estimates, labelled as such, and superseded by live data whenever the database is available.
Configuring it yourself
Three secrets, all optional and all independently degradable:
| Secret | Without it |
|---|---|
FDC_API_KEY |
Falls back to the built-in table of ~140 ingredients |
ANTHROPIC_API_KEY |
Falls back to the rule-based parser; photographing a recipe and scanning a label are unavailable |
ADMIN_KEY |
Only the manual refresh sweep is disabled |
- Get a free FoodData Central key at fdc.nal.usda.gov/api-key-signup.html — instant, email only.
- Get an Anthropic API key at console.anthropic.com.
- Set them on the deployment:
npx wrangler pages secret put FDC_API_KEY, and the same for the others.
Keys live on the server and are never sent to a browser.
What it costs to run
Parsing uses Claude Haiku 4.5 at $1 per million input tokens and $5 per million output. A typical recipe is about 1,200 input and 700 output tokens — roughly half a cent — and because results are cached by recipe hash, the amortised cost falls toward zero as the same recipes recur. USDA lookups are free and, after the mirror warms, mostly don’t happen at all.