Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Our URLs are the questions, 417 of them starting with is- and 44 with does-, and the guard list naming the paths they must never take is missing five

Munchable answers a particular kind of question: whether one ingredient is a problem for one gut condition. On the website each of those pairs is a page, and the page's URL is the question as a person would type it. /

Munchable answers a particular kind of question: whether one ingredient is a problem for one gut condition. On the website each of those pairs is a page, and the page's URL is the question as a person would type it.

/is-onion-low-fodmap
/is-milk-high-in-lactose
/does-chocolate-cause-reflux
/is-oat-gluten-free

Four hundred and sixty-one of them, indexed here. 417 start with is- and 44 start with does-, and that split is the whole subject of this post, because the easy version of this feature does not have a split in it at all.

The template you would write first is wrong

The first design is one phrasing with the condition dropped into it. /is-{ingredient}-ok-for-{condition}, an h1 that says "Is onion OK for IBS?", and 461 pages in an afternoon.

It is wrong because people do not search for the thing you are modelling. They search for the phrase their condition taught them. Somebody with IBS has been told about low FODMAP, and types "low FODMAP". Somebody with reflux does not ask whether a food is OK, they ask whether it causes reflux. Somebody avoiding gluten types the words "gluten free" even when what they want to know is whether a particular word on a label means a grain. The question is not one question in eight costumes, it is eight questions.

So the phrasing is a table, one entry per condition:

export const QUESTION_PHRASING: Record<ConditionId, {
  slug: (ingredient: string) => string;
  ask: (name: string) => string;
  flagMeansYes: boolean;
  caution: string;
}> = {
  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',
  },
  // ...six more
};

One table, four outputs: the URL segment, the h1, the title tag, and the question inside the page's FAQ structured data. They are the same string by construction, which matters more than it sounds. A page whose <title> asks one thing and whose heading asks a slightly different thing is a page where somebody will eventually fix one of the two.

flagMeansYes is the field I would not have predicted

Here is the part that caught me out, and I think it generalises to any product that turns an internal enum into a sentence.

The logic returns one of a small set of values for a given ingredient and condition. Call the interesting one "flagged". Now write the heading of the page from it:

  • "Is onion low FODMAP?" and the ingredient is flagged. The answer is no.
  • "Does chocolate cause reflux?" and the ingredient is flagged. The answer is yes.

Identical input, opposite words, because the question's polarity is a property of the question and not of the data. Deriving the heading from the verdict alone prints the opposite of the truth on a whole class of pages, and it does it in the largest type on the page. Hence a boolean, in the same table as the phrasing, read by one function:

function verdictLead(condition: ConditionId, verdict: VerdictValue): string {
  const phrasing = QUESTION_PHRASING[condition];
  switch (verdict) {
    case 'caution': return phrasing.caution;
    case 'good':    return phrasing.flagMeansYes ? 'No' : 'Yes';
    case 'avoid':   return phrasing.flagMeansYes ? 'Yes' : 'No';
    default:        return 'Munchable cannot say';
  }
}

The middle value gets its own sentence per condition rather than one shared word, for the same reason. "It depends on how much", "For a lot of people, yes", "Worth limiting" and "Only if the pack says so" are all the same internal value, and none of them is a decent paraphrase of any other. An enum value is a label for a bucket, not a translation of it, and the distance between the two is where a product sounds either careful or glib.

A dynamic segment at the root of a domain is a loaded gun

These pages are not under a folder. The route is app/[question]/page.tsx, so the dynamic segment sits at the site root, which is what makes the URLs readable and what makes the next problem possible: a generated slug could collide with a real route.

If it ever does, nothing breaks loudly. Next resolves static routes before dynamic ones, so the real page wins and the generated page simply does not exist. No error, no 404, no duplicate. One page in the set is quietly unreachable and the sitemap claims it is there.

The guard is a set and a throw, evaluated when the module loads, so a bad build is a failed build:

const RESERVED_PATHS = new Set([
  '', 'answers', 'api', 'apple-icon', 'auth', 'conditions', 'continue',
  'get-started', 'hidden-gluten-ingredients', 'icon.svg', 'licenses',
  'opengraph-image.png', 'premium', 'privacy', 'robots.txt', 'sitemap.xml',
  'terms',
]);

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

Seventeen entries. I counted the top-level route segments the site actually has while writing this, and five of them are not in that list: about, admin, maintenance, recipes and support. The check is therefore not the complete guard it reads like.

It is also not currently reachable, which is why the gap has survived. Every slug in the set begins is- or does-, and no page on the site is called is-anything, so a collision is impossible today for a reason that has nothing to do with the list. That is exactly the kind of safety I distrust: the list is insurance against a future phrasing that does not start with a verb, and insurance with five holes in it is worth less than its reputation. Deriving the reserved set from the filesystem instead of typing it is the obvious fix and the honest statement of where this stands is that I have not written it yet.

One Map, for a reason that has nothing to do with speed

The lookup from a URL segment to a page is a Map, and the comment next to it is the only interesting part:

/**
 * A `Map`, not an object literal, because the key is a URL segment:
 * `lookup['__proto__']` on a plain object answers with something inherited from
 * `Object.prototype`, which passes an `if (!page)` guard and then throws.
 */
export function answerPage(question: string): IngredientConditionPage | null {
  const ref = ANSWER_BY_QUESTION.get(question);
  if (!ref) return null;
  // ...
}

Any time the key of a lookup comes from a URL, a form field or a header, a plain object has entries in it that your code did not put there. /__proto__ is a URL anybody can request. Map has no prototype keys, so the truthiness guard does what it looks like it does.

See the shapes

The quickest way to see why one template would not have done is to open two of them next to each other: Is onion low FODMAP? and Does chocolate cause reflux?, then a third that is a different kind of question again, Is oat gluten free?. Same code, same data, three different sentences, and the URL in the address bar is the question each one is for.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.