DEV Community

Daniel Pertu
Daniel Pertu

Posted on

373 static pages that answer by running the scanner, and the filler ingredient that answered instead

Munchable is a gut health app: you scan a barcode in a shop and it tells you whether the product fits your conditions. It also has 373 pages on its marketing site shaped like /is-onion-low-fodmap and /does-chocolate-cause-reflux, one per ingredient and condition pair we have something to say about.

Those pages are the obvious SEO move and the obvious content trap. Write them by hand and they are 373 opportunities for the site to claim something the product does not do. Generate them from a template and they are 373 pages of "onion may be a concern for some people", which helps nobody and ranks accordingly.

What we do instead is build a product that does not exist, run the actual scanner over it, and print what comes back.

Here is one, live: munchable.app/is-onion-low-fodmap. The heading is "No, and here is why", and under it sits the line the app itself would show on a real pack:

High FODMAP fructans as a main ingredient

No human wrote that sentence for that page. The page called fitCheck, which is the same exported function the phone calls after the camera reads a barcode, and printed the reason it got back.

The probe

The engine takes a normalized product. A page is about one ingredient. So the page makes a product out of the ingredient:

function probe(tag: string, condition: ConditionId, asMainIngredient: boolean): Probe {
  const ingredientsTags = asMainIngredient ? [tag, ...INERT_FILLER] : [...INERT_FILLER, tag];
  const result = fitCheck(
    {
      barcode: '0000000000000',
      ingredientsTags,
      statesTags: ['en:ingredients-completed'],
    } as never,
    { conditions: [condition] },
  );
  const per = result.perCondition[0];
  return { verdict: per?.verdict ?? 'unknown', reason: per?.reasons[0]?.message };
}
Enter fullscreen mode Exit fullscreen mode

Three things in there are load bearing.

statesTags: ['en:ingredients-completed'] marks the fake product as having a fully readable label. Without it the engine lands on an unknown confidence, because a product whose ingredients list is incomplete cannot be scored with confidence. That is correct behaviour for a scan and wrong for this page: nobody is asking about a pack with a torn label, they are asking about onion.

The call runs twice, once with the ingredient first and once with it last, because position on a label changes the verdict. The engine escalates a flagged ingredient when it is one of the product's main ingredients, exactly the way a reader would: sugar in position two is a different product from sugar in position eleven. The page reads both and only mentions position when the two disagree:

const asMain = probe(entry.tag, ref.condition, true);
const asMinor = probe(entry.tag, ref.condition, false);
const positionMatters = asMain.verdict !== asMinor.verdict;
Enter fullscreen mode Exit fullscreen mode

And then there is INERT_FILLER, which is where this got interesting.

The filler started answering the question

To make the ingredient appear "further down the label" you need something above it. That padding has to be inert, or it contributes reasons of its own and the page is no longer about the ingredient in its title.

Our filler list held sunflower oil for months and it was fine, because no rule set had anything to say about sunflower oil. Then the bile acid malabsorption rules learned to read fat-dense ingredients. Overnight, every BAM probe was reading the filler: the page asked "is almond OK with BAM" and the engine answered, accurately, about the oil we had quietly poured into the fake product.

Nothing crashed. The pages rendered. The build passed. They were simply, quietly, all about sunflower oil.

The fix is a comment as much as a code change, because the next person needs to know this list is not arbitrary:

/**
 * 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. 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', 'en:rice-flour', 'en:rice', 'en:citric-acid'];
Enter fullscreen mode Exit fullscreen mode

The general lesson is not "pick better filler". It is that a synthetic input for a system under active development is itself a dependency on that system's current behaviour, and it fails silently rather than loudly. A test fixture at least has an assertion next to it. A content fixture just publishes.

The verdict is not the answer

The second problem was polarity, and it is the one I would not have predicted.

The engine returns a verdict: good, caution, avoid, unknown. Perfectly sensible, and completely insufficient to write a headline, because the questions do not all point the same way.

"Is onion low FODMAP?" and "Does chocolate cause reflux?" are both answered by a flag, but a flag answers the first one no and the second one yes. Deriving the heading from the verdict alone would print the opposite of the verdict on roughly half the site.

So the phrasing table carries the polarity along with the words:

ibs: {
  slug: (i) => `is-${i}-low-fodmap`,
  ask: (n) => `Is ${n} low FODMAP?`,
  flagMeansYes: false,
  caution: 'It depends on how much',
},
gerd: {
  slug: (i) => `does-${i}-cause-reflux`,
  ask: (n) => `Does ${n} cause reflux?`,
  flagMeansYes: true,
  caution: 'For a lot of people, yes',
},
Enter fullscreen mode Exit fullscreen mode

That object is the only place the question exists. The URL segment, the h1, the <title>, the FAQ structured data and the link text on the index all come out of it, so a page's URL and the question it answers cannot drift apart. It is a small thing that removes an entire category of stale-content bug: there is no second copy of the question to forget to update.

caution is spelled out per condition rather than shared for a related reason. The engine raises that one verdict for genuinely different underlying situations: a moderate lactose load is a question of how much you eat, while a reflux trigger is a question of who you are. "It depends on how much" is honest for one and misleading for the other.

Pairs with nothing to say get no page

The pair set is not the cross product. Twelve hundred ingredient-condition combinations exist on paper; a page exists only where the rules actually have something to say:

for (const entry of INGREDIENTS) {
  for (const fact of ingredientFacts(entry)) {
    const question = QUESTION_PHRASING[fact.condition].slug(entry.slug);
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

A page reading "onion is not relevant to bile acid malabsorption" is thin content and, more to the point, nobody searched for it. The count moves every time a rule map grows, which is why it is not written down anywhere in the source. It is 373 today. I got that number by counting links on the live index rather than from a constant, which is the correct way round.

Bile acid malabsorption is the clearest case: it generated zero pages for a long time. That axis reads the label's fat figure, and a one-ingredient probe has no nutrition panel, so every BAM probe came back unknown and every BAM pair was skipped. Pages appeared only once the rule set could answer from how fat-dense an ingredient is, without a panel. The content strategy did not change. The engine got better and the pages showed up.

Two smaller traps worth stealing

The route is app/[question]/page.tsx, a dynamic segment at the site root. That is deliberate, because the URL should be the question and nothing else, but it means a generated slug that collided with a real route would produce a page nobody could reach. Next resolves static routes first, so /privacy would win and the answer page would vanish without an error. Every slug currently starts is- or does-, so it cannot happen today, which is exactly when to add the guard:

if (RESERVED_PATHS.has(question)) {
  throw new Error(`Answer page slug "${question}" collides with a real route.`);
}
Enter fullscreen mode Exit fullscreen mode

It throws at module load, so it fails the build rather than a page view.

And the lookup from slug to page is a Map, not an object literal, because the key is a URL segment somebody else controls. lookup['__proto__'] on a plain object returns something inherited from Object.prototype, which sails straight through an if (!page) return notFound() guard and then throws further down. A Map returns undefined like an adult.

What it costs

Rendering is static. export const dynamic = 'error' on the route means the build fails if anything in the tree ever tries to read a request, so those 373 pages are files on a CDN and the engine runs at build time only.

The real cost is that these pages cannot be tuned independently. If a rule changes, the copy changes, and there is no way to soften a page for a search term without changing what the app does on a real pack. I consider that the feature. The whole reason to publish this shape of page is that it is the product talking.

Have a click around: the index groups every one of them by condition at munchable.app/answers, and each guide such as IBS and low FODMAP links into its own set. If you find a page whose answer contradicts the app, that is a bug in one rule set, not in one page, which is the point.

Top comments (0)