DEV Community

Daniel Pertu
Daniel Pertu

Posted on

38 recipes, seven conditions, and the seven collection pages we did not build

Munchable has a hand-written recipe library: 38 recipes, each checked against seven gut conditions by the same rules engine that runs on a barcode in the app. Every recipe is a public page. Every page ranks for its own long tail.

The obvious next move, and the one every programmatic SEO guide would tell you to make, is seven collection pages: low FODMAP recipes, reflux recipes, lactose free recipes, and so on. Seven head terms, seven internal hubs, seven URLs built from data we already have.

We did not build them. The reason is a distribution, and I think it generalises.

Count the overlap before you generate the pages

Here is what the seven lists would have contained. 37 of the 38 recipes suit low FODMAP. 30 suit reflux. The thinnest condition still has 30.

Those are not seven collections. They are one collection, printed seven times with a different heading, with most pages sharing over 80 percent of their items with every sibling. Google is quite good at noticing that, and the outcome is not seven ranking pages, it is a set of near-duplicates splitting the same internal link equity and spending crawl budget re-reading lists it has already seen.

The test I would now apply before generating any collection page:

Does this list diverge from its siblings enough that a reader who has seen one would learn something from the second?

At 37 out of 38, no. The comment in the index page records the condition for revisiting it:

Collections become worth building when the library is large enough that the
per-condition lists genuinely diverge.
Enter fullscreen mode Exit fullscreen mode

That is a real plan rather than a no. When the library is 300 recipes and low FODMAP holds 210 of them while gastroparesis holds 60, the pages are different pages and they get built.

Group on the axis where the sets are actually disjoint

So the recipe index is grouped by meal: breakfast, lunch, dinner, snack. A recipe can carry more than one meal type, but a breakfast list and a dinner list genuinely differ, which is the whole property the condition lists lacked.

const byMeal = MEAL_TYPES.map((meal) => ({
  meal,
  pages: RECIPE_PAGES.filter((page) => page.recipe.mealTypes.includes(meal)),
})).filter((group) => group.pages.length > 0);
Enter fullscreen mode Exit fullscreen mode

The condition-shaped linking still happens, just not through seven thin hubs. Each of the seven condition guides links to the recipes it clears, and each recipe page links back to the guides it suits. The hub already existed and already ranks for the head term; it did not need a near-copy of itself.

The claim on the page is computed, not written

Every recipe page has a "Who this suits" block. Nobody types those lines. Each page is built by running the production engine against the recipe, once per condition:

function suits(recipe: Recipe, condition: ConditionId): boolean {
  const fit = fitCheck(toProduct(recipe), {
    conditions: [condition],
    settings: { lactose: { sensitivity: 'high' } },
  });
  return fit.verdict === 'good';
}

const conditions = CONDITION_IDS.filter((id) => suits(recipe, id));
Enter fullscreen mode Exit fullscreen mode

Two things in that snippet are doing a lot of work.

toProduct(recipe) converts a recipe into the same shape a scanned product has, so the recipe goes through the same code path as a jar of pasta sauce. There is no second "recipe checker" that can disagree with the scanner. The engine is a package that both the marketing site and the mobile app import, so a recipe cannot say one thing on the web and another thing on somebody's phone.

sensitivity: 'high' is the more interesting decision. Lactose sensitivity is a user setting, and a public page has no user. We read the claim at the strictest setting the app offers, so the published line can never be softer than the answer the app would give the person who acts on it. A page that says "suits lactose intolerance" to a visitor whose app would later say "caution" is worse than no page.

The same verdict that belongs on the web does not belong in the app

The app filters the recipe library to the reader's profile and then shows no verdicts at all. The web pages label everything. That asymmetry was deliberate, and the comment that explains it is the part of this I would most want to keep:

In the app a reader has a profile, so the library is filtered for them and a verdict on top of that would be the app arguing with a decision it already made. A web visitor has no profile: "suits a low FODMAP pattern" is an index label rather than a judgement about them, and it is also the thing they searched for.

Same data, same engine, opposite presentation, because the reader's context is different. It is worth asking of any shared component whether the thing you are reusing is the data or the interface.

Saying the shortest true thing

With 37 of 38 recipes clearing low FODMAP, most recipes suit six or seven conditions, and enumerating them produces a card that is five lines of condition names and no recipe. So the line is generated by rule:

const missing = CONDITION_IDS.filter((id) => !conditions.includes(id));
const suitsLine =
  conditions.length === 0
    ? ''
    : missing.length === 0
      ? 'all seven conditions'
      : missing.length <= 2
        ? `every condition except ${joinNames(missing.map((id) => CONDITION_LABELS[id]))}`
        : joinNames(conditions.map((id) => CONDITION_LABELS[id]));
Enter fullscreen mode Exit fullscreen mode

Inverting near misses is the good bit. "Every condition except gastroparesis" is shorter than naming the other six and more useful, because the exception is exactly what a reader with gastroparesis is scanning for.

One detail cost a rewrite. The line uses the engine's short labels, not the page headings, because two headings contain commas. Joining those into a list produced:

GERD and reflux, Lactose intolerance and IBD, Crohn's and colitis
Enter fullscreen mode Exit fullscreen mode

which reads as four conditions, or five, depending on the reader. If you join names into prose, check what happens when a name contains your separator.

Meta descriptions are generated from what the page says

Hand-written meta descriptions are a maintenance trap: dozens of them drift out of agreement with their pages the first time a quantity changes, and nothing tells you. So they are built from the same structure the page renders:

metaDescription:
  guides.length > 0
    ? `${recipe.summary} ${recipe.minutes} minutes, serves ${recipe.serves}. Checked against ${suitsLine}.`
    : `${recipe.summary} ${recipe.minutes} minutes, serves ${recipe.serves}.`,
Enter fullscreen mode Exit fullscreen mode

You can check this without taking my word for it. View source on the ginger tofu recipe and the description reads:

Golden fried firm tofu and green beans in a ginger and tamari glaze, over white rice. 25 minutes, serves 2. Checked against all seven conditions.

Then read the page itself: same summary, same timing, same seven conditions, with each one linked to its guide. Change a quantity so the engine's answer moves, and the search snippet moves with it in the same build.

The pages are fully static (export const dynamic = 'error', so a stray dynamic API turns into a build failure rather than a per-request render). The library ships in the repository, so there is nothing to revalidate, and the JSON-LD ItemList on the index is generated from the same array that renders the cards.

The library is deliberately not all green

The last piece of restraint is in the content itself. The library includes a rich salmon dish that is an avoid for bile acid malabsorption and an oat breakfast that is a caution for gastroparesis. It would have been easy to write 38 recipes that clear everything, and the result would be a library that teaches the reader nothing and, worse, that nobody has any reason to believe.

A list where everything fits is indistinguishable from a list that was never checked.

Go and look

  • The recipe index, grouped by meal, is the page that would have been seven pages.
  • The condition guides are the hubs the collections would have competed with. Open one and follow it to the recipes it clears.
  • Any recipe page, for example ginger tofu with green beans and rice, shows the computed "who this suits" block. Compare it to the meta description in view source.
  • If you find a recipe that suits fewer conditions than the others, that is the engine disagreeing with the recipe, not an oversight.

Top comments (0)