Munchable answers one kind of question: is this ingredient a problem for the gut condition I have. People do not search for that by browsing a taxonomy. They type the question, usually in the shape "is onion low fodmap", and whatever they land on has about one line to answer it.
So the URL is the question. Not /answers/ibs/onion, not /ingredients/onion?condition=ibs, just munchable.app/is-onion-low-fodmap. There are 373 of these pages, out of a 426-URL sitemap, and every one of them sits directly under the site root.
That is a nice URL and a genuinely dangerous route. Here is the whole of what keeps it safe.
The route is nine lines, and two of them are the point
// app/[question]/page.tsx
export const dynamic = 'error';
export const dynamicParams = false;
export function generateStaticParams() {
return ANSWER_PAGES.map((page) => ({ question: page.question }));
}
dynamicParams = false is the line I would put in bold if dev.to let me bold a line of code. A dynamic segment at the root matches any unmatched top-level path. Without that flag, /is-onion-low-fodmpa (note the typo), /wp-login.php and /free-fodmap-pdf-download all render this route. The page would call notFound(), because the lookup misses, so a human sees a 404 page. The difference is what the response looked like while Next worked that out: a route that exists, rendered on demand, for an unbounded set of URLs. Every scraper and every mistyped backlink becomes a page in the crawl frontier.
With dynamicParams = false, the only paths this route answers are the 373 that generateStaticParams returned. Everything else is a real 404 before any of my code runs. dynamic = 'error' is the belt to that braces: if anything in the subtree ever reaches for a request header or a cookie and quietly opts the page into dynamic rendering, the build fails instead of shipping 373 pages that render per request.
notFound() is still in the component. It is unreachable in production and it is the thing that makes the file honest about its own contract.
Route precedence is the failure nobody notices
Next resolves static routes before dynamic ones. /terms is a real page, so /terms will never reach [question].
Which sounds like a safety feature until you say it the other way round. If the content layer ever generates a slug that happens to equal a real route, the real route wins, and the answer page for that pair does not 404. It does not error. It does not log. It simply does not exist, while every index, sitemap and internal link on the site goes on pointing at a URL that renders something else entirely.
So the slugs are checked against the routes, at module load, in the file that builds them:
/**
* Top-level paths these pages must never take, checked at module load.
*/
const RESERVED_PATHS = new Set([
'', 'answers', 'api', 'apple-icon', 'auth', 'conditions', 'continue',
'get-started', 'icon.svg', 'licenses', 'opengraph-image.png', 'premium',
/* ... */ 'terms',
]);
for (const entry of INGREDIENTS) {
for (const fact of ingredientFacts(entry)) {
const question = QUESTION_PHRASING[fact.condition].slug(entry.slug);
if (RESERVED_PATHS.has(question)) {
throw new Error(`Answer page slug "${question}" collides with a real route.`);
}
// ...
}
}
A thrown error at module load in a file imported by the sitemap, the index page and the route itself means a collision cannot be deployed. It fails next build, which is the only failure mode I trust for this, because the alternative is a page that is missing in a way that looks exactly like a page that was never built.
Today every slug starts is- or does-, so nothing can collide. The guard is not there for today. It is there for the day somebody adds a question phrasing that starts with a bare noun, six months after writing this post, with no memory of why that would matter.
The lookup is a Map, and that is not a style choice
Once the slugs exist, the route has to turn a URL segment back into a page:
/**
* 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;
// ...
}
The input here is a path segment from a stranger. With an object literal as the table, /__proto__ and /constructor both return a truthy value from the prototype chain, sail past the if (!ref) return null guard, and then explode when the next line reads a property off it. A Map has no prototype keys, so get('__proto__') is undefined like every other miss.
Static params mean this is unreachable in production too. It is still the right default for any lookup whose key came off the wire, and it costs nothing.
One table decides the slug, the h1, the title tag and the FAQ entry
The phrasing differs per condition, because the phrase people actually type differs per condition. "Low FODMAP" for IBS. "High in lactose" for lactose intolerance. "Cause reflux" for GERD. Forcing all seven through one template would produce URLs nobody searches for.
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' },
// ...
};
One record, seven entries, and the slug and the sentence come out of the same place. The <h1>, the <title>, the breadcrumb and the single FAQPage question in the JSON-LD are all built from ask(), so the question in the search result, the question in the heading and the question in the structured data cannot drift apart. That is the entire reason the table holds functions rather than strings.
flagMeansYes is there because the questions do not all point the same way. "Is this low FODMAP" and "does this cause reflux" want opposite answers from the same flag, and exactly one place in the codebase is allowed to know that.
One more small thing with a disproportionate effect on the search result: the <title> is the question and nothing else, and the meta description leads with the answer rather than with a promise to answer. A snippet that says "the answer is no, because..." is useful before the click. A snippet that says "find out whether onion is low FODMAP with Munchable" is an advert for a page the reader is already looking at.
Why there is an index page at all
munchable.app/answers exists purely for reachability. Pages that are only linked from a sitemap get crawled slowly and treated as unimportant, and 373 pages linked from nowhere is a very good way to have 373 pages nobody ever sees.
It is grouped by condition rather than alphabetically across the whole set, because a reader has one condition and wants the shortlist for it, not an A to Z of everything the app knows:
const groups = CONDITION_PAGES.map((guide) => ({
guide,
questions: ANSWER_PAGES.filter((p) => p.condition === guide.condition)
.map((p) => ({ page: p, entry: INGREDIENTS_BY_SLUG.get(p.ingredientSlug) }))
.sort((a, b) => a.entry.name.localeCompare(b.entry.name)),
})).filter((g) => g.questions.length > 0);
The last filter is the small honest bit. A condition with nothing to list gets no empty heading on the index, and a pair the app has nothing to say about gets no page in the first place. "Onion is not relevant to this condition" is a page that answers a question nobody asked.
Look at it yourself
Everything above is observable from outside, which is the nice thing about writing about routing rather than about business logic:
- munchable.app/answers is the index, grouped by condition. Click any question and look at the address bar: the URL has no prefix segment, which is the whole subject of this post.
- munchable.app/is-onion-low-fodmap is one of the 373, prerendered at build time, with the question as its title tag. munchable.app/does-chocolate-cause-reflux is the same route with the GERD phrasing, so you can see the two slug shapes next to each other.
- Add a character to either URL and you get a real 404, not a soft one. That is
dynamicParams = falsedoing its job. -
munchable.app/conditions/low-fodmap-ibs is where the breadcrumbs point: a static route, resolved before the dynamic segment ever gets a look, and the reason
conditionsis in that reserved Set.
If you are building something similar: the root-level dynamic segment is worth it when the URL is the search query, and it is only worth it with dynamicParams = false, a collision guard that fails the build, and a Map for the lookup.
Top comments (0)