Munchable is two apps in one repo: an Expo app that reads a food label, and a Next.js site that publishes the written answers. The site's sitemap currently holds 516 URLs, and 461 of them are one question each, at the root, phrased the way somebody types it into a search box.
Last week it gained one more URL: a reference page that is a long graded list rather than a question. 190 lines of page component, an 80 line data module next to it, 70 KB of HTML at the other end.
The thing worth writing down is that the list on it was never written. Every entry was already in the repo, because the question pages are generated from the same index. The page is a map over that index, and what it actually added was one URL, 88 internal links, and about ten paragraphs of prose.
The page is a map, not a document
The whole body of it is this shape:
{GROUPS.map((group) => ( <section key={group.id} id={group.id}> <h2> {group.heading} ({group.words.length}) </h2> <p>{group.blurb}</p> <ul className="answer-list"> {group.words.map((entry) => ( <li key={entry.slug}> <Link href={questionPathFor(entry.slug, CONDITION)}>{entry.name}</Link> {entry.aliases.length > 0 && <> (also {entry.aliases.join(', ')})</>} </li> ))} </ul> </section> ))} Three groups, 88 entries between them, 88 links out. The number in each heading is group.words.length, not a number anybody typed, which is the part I would defend hardest in review. A hand-typed count is a sentence that is true on the day it is written and silently false afterwards, and this repo has already been bitten by exactly that: there are comments in it that still say the site is about 190 URLs.
The grouping itself is one filter in the data module:
export const GROUPS: WordGroup[] = GRADES.map((grade) => ({ grade, ...BLURBS[grade], words: INDEX.filter((entry) => entry.grade === grade), })); INDEX is the same array the 461 question pages are built from. So the reference page cannot list an entry the site has no page for, and it cannot miss one either.
88 links to URLs the router already serves
Each item links through questionPathFor(slug, condition), which is the same helper the question pages use to produce their own canonical URL and the same one the sitemap calls. There is no second place where a URL is assembled, so a reference page full of links to 404s is not a thing that can happen here without the sitemap breaking in the same commit.
That is the main reason a page like this is worth having at all. The individual pages answer one word each, which is what a search for one word deserves. Nobody searching for the whole vocabulary wants to be handed 88 tabs. The index page is the head term, and it is also 88 internal links pointing at pages that otherwise rely on one big directory page for discovery.
The prose it did add, and the one sentence it refused to write
Ten paragraphs across five sections, and they live in two places on purpose. The paragraph that explains what a group means lives in the data module, next to the filter that builds the group, because it is a description of the data and not of the page. The lede and the surrounding sections live in the page.
One sentence on the page is a standing caveat that the app also shows. That one is not typed into the page at all: it is read off a real result from the shared package, so the page and the app cannot end up promising different things. The general rule in this repo is that if a user-visible sentence exists in the product, the marketing page imports it rather than quoting it.
The metadata is written in literals, deliberately
// Written out here rather than pulled from a constant: a test reads these // literals out of the source to check them against what a search result can // show, and a constant is invisible to it. export const metadata: Metadata = pageMetadata({ title: 'Hidden gluten on a food label', description: 'The label words that mean a gluten grain, graded: ...', path: '/hidden-gluten-ingredients', type: 'article', }); Every page on this site has a title and description budget, because the only version of them most people ever see is the one Google truncates. The test that enforces it reads the page files as text and pulls the string out of the source, which works for a literal and does not work for TITLE imported from somewhere. So the literal is the interface. The rendered title is 41 characters with the brand suffix on it, and the description is 144 against a budget of 160.
The same sentence has to appear in the page's JSON-LD, and that copy is read back off the metadata object rather than written a second time:
{ '@type': 'Article', headline: 'Hidden gluten: the label words that mean a grain', description: String(metadata.description), url: absoluteUrl('/hidden-gluten-ingredients'), } One line that makes the whole page a build-time assertion
export const dynamic = 'error'; This is the most useful line in the file. It does not make the page static, the page already was. It makes the build fail if anything in the tree ever reaches for a request: a header, a cookie, an uncached fetch. A derived page that quietly becomes dynamic is a page that starts running a per-request render of a list that changes a few times a year, and the symptom is a bill rather than an error. dynamic = 'error' turns it into an error.
Telling the search engines, but only when there is something to tell
The site pings IndexNow, and the submitter works off content fingerprints rather than deploys, because a deploy is not a change and pinging on every deploy is how you teach a crawler to ignore you. The new page needed its own fingerprint, and the interesting part is what went into the hash:
{ path: '/hidden-gluten-ingredients', hash: fingerprint(GROUPS.map((group) => [group.grade, group.words.map((w) => w.slug)])), } The grades and the slugs, and nothing else. Re-wording a paragraph on the page does not announce it. Adding an entry, removing one, or moving one between groups does. That is the honest definition of "this page changed" for a page whose content is a list.
The test that stops it quietly emptying itself
The list of entries the page promises is written by hand in one file. The content for each entry comes from another. That means an entry can go dead in two ways that nothing else notices: a typo or a renamed id stops it resolving at all, and a re-keyed source stops it reaching anything with something to say. Either way the page just gets shorter, and both a passing build and a valid sitemap will cheerfully serve the shorter version.
So the guard is four assertions, not one:
it('every promised entry is a real id', () => { const failures = PROMISED.map(canonicalise).filter((id) => !isKnown(id)); assert.deepEqual(failures, []); }); it('every promised entry has a page, and the page answers its question', () => { // ...and the page's answer is not the "we have nothing to say" one. }); it('the entries that are their own answer get no page at all', () => { // A handful of entries are in the source because a label prints them, // and asking the question about them is nonsense. Those must not publish. }); assert.deepEqual(failures, []) rather than assert.ok(failures.length === 0) is a small thing that pays for itself every time it fails: the diff names the entries, so the test output is the fix list.
Have a look at it
The page is live: munchable.app/hidden-gluten-ingredients. Click any entry in any of the three lists and you land on that entry's own page, which was generated from the same row that drew the line you clicked. The answers directory is the other half of the discovery story, and the conditions index is where the written guides sit.
If you build content pages out of data you already hold, the two habits I would take from this one are the array length in the heading and the fingerprint over the data rather than over the deploy. Both of them are the difference between a page that stays true and a page that was true once.

