I wrote recently about the evidence discipline behind our generated pages: no URL without a citation, and an expiry date on every source. This post is about the other half of that problem, which is where the verdict on each page comes from.
The rule we settled on for Munchable:
No verdict on a generated page is written by a human, templated, or transcribed from the rules. Every one is produced by running the production engine at build time.
The pages cannot drift from the app, because there is nothing to drift. Go and look: is onion low FODMAP? and does chocolate cause reflux? are two of them, and the full index is at munchable.app/answers.
The idea is not the interesting part. The two things that went wrong when we implemented it are.
What the pages are
Munchable scores food for seven gut conditions. So there is a natural grid: every ingredient we know something about, times seven conditions. Each cell is a question a real person types into a search box.
The slug is the question:
/is-onion-low-fodmap
/does-chocolate-cause-reflux
/is-almond-ok-with-gastroparesis
/is-bacon-ok-with-ibd
/is-apple-juice-ok-for-sibo
Note that the phrasing is not one template with the condition swapped in. People search different shapes for different conditions. "Low FODMAP" for IBS. "High in lactose" for lactose intolerance. "Cause reflux" for GERD. "Ok with" for the inflammatory conditions. Forcing all seven through one template produces URLs nobody searches for.
So a single table decides the phrasing per condition, and the slug, the h1, the <title> and the FAQ question are all derived from it. One place, four outputs, no possibility of the URL saying one thing and the heading another.
Rule one: no page for a pair with nothing to say
The grid is ingredients times seven. The site is much smaller than that, on purpose.
Pairs with nothing to say get no page: a page that says "onion is not relevant to bile acid malabsorption" is thin content, and it is not what anybody searched for either.
This is the discipline that the bad version of programmatic SEO skips, because the whole appeal of generating pages is that generating more is free. It is free to generate and expensive to rank. If a rule set has no opinion about an ingredient, that cell does not become a URL.
The result is 375 URLs in the sitemap rather than several thousand, and every one of them answers the question in its own title.
Rule two: the verdict comes from fitCheck, not from prose
Here is the bit I would actually recommend copying.
The obvious implementation is to read the rule data and write a sentence about it. That gives you two descriptions of the same rule: the executable one in the engine, and the English one on the page. They agree on the day you write them and never again.
Instead, each page constructs a one-ingredient product and runs it through fitCheck, the same function the app calls when you scan a barcode. The verdict, the tier and the reason text on the page are literally what the app would say.
import { fitCheck, type ConditionId, type VerdictValue } from '@munchable/rules-engine';
If a rule changes, the pages change with it on the next build. Nobody edits anything. There is no drift because there is only one source.
There is a pleasant secondary effect: these pages are a 375-case integration test of the rules engine that happens to also be your marketing site. A rule change that produces an absurd verdict shows up as an absurd sentence on a public page.
Rule three: probe it twice, because position changes the answer
This is where it gets genuinely interesting, and where a naive implementation would produce subtly wrong pages.
The engine reads ingredient order. Ingredient lists are ordered by weight, so an ingredient near the front is a main component and an ingredient at the back is a trace. The engine escalates a flagged ingredient when it appears in one of the first three positions.
Which means "is onion low FODMAP" does not have one answer. It has two: onion as the main ingredient, and onion as a minor one.
So each page runs the probe twice. Once with the ingredient first, once with it last, and reports both. To put it last you need padding, and the padding is where the sharp edges live:
/**
* Ingredients that no rule set flags, used to pad the probe product.
*
* NO FATS OR OILS HERE. This list held `en:sunflower-oil` until the BAM rule set
* learned to read fat-dense ingredients, at which point the filler started
* answering the question: every BAM probe read the oil rather than the
* ingredient under test. There are five entries because the engine counts the
* first three positions as primary, so the ingredient under test has to sit
* past them to be read as a minor one.
*/
const INERT_FILLER = [
'en:water',
'en:salt',
// ...
];
That comment documents a real failure. The filler was inert when it was written. Then a rule set learned something new, the filler stopped being inert, and every bile acid malabsorption page quietly started reporting a verdict about sunflower oil instead of the ingredient in the title. Nothing threw. The pages looked fine.
Two general lessons in that one constant:
- "Inert" is a property of your rules at a point in time, not a property of the ingredient. Any test fixture chosen to be neutral needs re-checking whenever the thing it is neutral with respect to gets smarter.
- Five entries, because the engine treats the first three positions as primary. The size of the fixture is derived from a documented engine behaviour, not picked because it looked like enough.
Rule four: one place decides canonical URLs
Boring, load bearing, and the classic way a site de-indexes itself quietly. Every canonical URL, sitemap entry and Open Graph tag resolves through a single module, so the canonical and the sitemap cannot disagree.
export function pageMetadata(opts: {
title: string; description: string; path: string; noIndex?: boolean;
}): Metadata {
const url = absoluteUrl(opts.path);
return {
// A noindex page gets NO canonical at all. `noindex` alongside a
// rel=canonical pointing elsewhere is the documented way to have the
// directive consolidated onto the canonical target instead of this page.
...(opts.noIndex ? {} : { alternates: { canonical: url } }),
openGraph: { title: opts.title, url, /* ... */ },
// ...
};
}
Two details that catch people:
A noindex page gets no canonical at all. Pairing noindex with a canonical pointing at another URL is a documented way to have the directive consolidated onto the target instead, which is the opposite of what you wanted.
Canonicals must never be inherited. In Next.js it is tempting to put shared metadata in a layout. robots is safe there, because it says the same true thing about every page underneath. A canonical is per-page by definition. A canonical in a layout means every child page claims to be the parent.
The test for whether you are doing the good version
One question: if your generated pages disappeared tomorrow, would anyone have lost anything?
If the answer is no, they are keyword pages, and an algorithm update will eventually agree. If the answer is yes, they are the product, rendered in a form a search engine can read.
Ours answer the exact question the app was built to answer, with the exact machinery the app uses to answer it. That is the whole trick. The SEO value is a side effect of publishing something that was already true.
Have a poke around munchable.app/answers, or pick a condition from munchable.app/conditions and follow it down. If you want the same engine pointed at a real barcode instead of a single ingredient, the free tier needs no card.
Top comments (0)