373 of our pages live at the root of the site. Not under /answers/, not under /guides/. The URL is the question itself:
- munchable.app/is-onion-low-fodmap
- munchable.app/does-chocolate-cause-reflux
- munchable.app/is-milk-high-in-lactose
Munchable is a barcode scanner for gut conditions, so "is this ingredient OK for me" is not a content-marketing angle for us, it is the product's entire job. Putting the question in the path is the honest URL. It also turns out to be the most dangerous route you can add to a Next.js App Router project, and the reason is worth five minutes.
A dynamic segment at the root matches everything
The file is app/[question]/page.tsx. A dynamic segment one level below the root, like app/recipes/[slug], only competes with other things under /recipes. A dynamic segment at the root competes with the entire URL space. Every path that no more specific route claims lands on it.
With the App Router's defaults, that means any path you have never heard of renders your page. Not a 404: a 200, with whatever your component does when the lookup misses. If that component renders an empty shell, you have just published an infinite supply of thin pages, and a crawler will happily take you up on it. Soft 404s at scale are one of the few genuinely self-inflicted SEO injuries.
Two lines prevent it:
export const dynamic = 'error';
export const dynamicParams = false;
export function generateStaticParams() {
return ANSWER_PAGES.map((page) => ({ question: page.question }));
}
generateStaticParams enumerates the slugs at build time. dynamicParams = false says that the enumeration is the whole set, so anything outside it is a real 404 rather than an on-demand render. dynamic = 'error' is the belt to that braces: the build fails if anything in the page reaches for a request-time API and quietly turns the route dynamic again.
You can check both halves from here. This one is a real page:
- /is-onion-low-fodmap returns 200
and these return a proper 404, which is the behaviour you want:
- /is-oinon-low-fodmap, a typo
- /is-banana-low-fodmap, a question we deliberately do not answer
The second one is the more interesting refusal
The typo 404 is a routing property. The banana 404 is an editorial decision, enforced by the generator.
Our ingredient index crossed with the seven gut conditions we support gives far more combinations than 373. We publish 373 of them. A pair gets a page only when the rule set for that condition has something to say about that ingredient. Banana is not flagged for IBS, so there is no page, because the page would have to say "banana is not relevant to low FODMAP" at length, and nobody searched for a paragraph of nothing.
That is the difference between a programmatic page set and a doorway page set, and it is enforced in code rather than in a style guide: the pair list comes from the engine, so a pair with no verdict cannot produce a URL even if someone wants one.
One module owns the phrasing, or four things drift
Every condition asks its question differently. People search "low FODMAP" for IBS, "high in lactose" for lactose intolerance, and "cause reflux" for GERD. Forcing all seven through one template would have produced URLs nobody types.
So each condition contributes a slug builder and a question sentence from one map:
ibs: { slug: (i) => `is-${i}-low-fodmap`, ask: (n) => `Is ${n} low FODMAP?` },
lactose: { slug: (i) => `is-${i}-high-in-lactose`, ask: (n) => `Is ${n} high in lactose?` },
gerd: { slug: (i) => `does-${i}-cause-reflux`, ask: (n) => `Does ${n} cause reflux?` },
The slug, the h1, the <title>, the breadcrumb and the FAQ structured data all read from that one entry. There is no second place to edit, so the URL and the question on the page cannot disagree.
The field in that map I did not expect to need is the polarity flag. The scanner returns one of a small set of verdicts. A "caution" verdict answers "Is beer low FODMAP?" with a no, and answers "Does chocolate cause reflux?" with a yes. Identical engine output, opposite headline. Derive the heading from the verdict alone and half your pages print the opposite of what they mean, in the largest text on the page, above a correct explanation. That is worse than being wrong quietly.
What the page is not allowed to do
None of these pages contain a written-down answer. The page builds a one-ingredient product, runs it through the same engine that answers a barcode scan in the app, and prints what comes back. Change a rule and the pages change with it, with no editing pass and no possibility of the marketing site and the product disagreeing in public.
One consequence worth knowing if you try this: position on a label changes the verdict, because an ingredient listed first is not the same as one listed twenty-second. So each page runs two probes, once with the ingredient at the front and once buried, and says so when the two differ. The padding used to bury it has to be inert. Ours included a vegetable oil until the day the engine learned to read fat-dense ingredients, at which point every probe for one condition started quietly answering a question about the padding rather than the ingredient.
Inert filler is only inert against the rules you had at the time.
Have a look
- The index, grouped by condition: munchable.app/answers
- A page whose two label positions disagree: is-onion-low-fodmap
- The condition guides those pages hang off: munchable.app/conditions
- The whole set, 426 URLs: munchable.app/sitemap.xml
If you run a dynamic segment at your own site root, the check takes ten seconds: request a path with a typo in it and look at the status code, not at the rendered page. A soft 404 looks fine in a browser.
Top comments (0)