Munchable answers one question per page: whether a given ingredient suits a given gut condition. There are 461 of those pages, plus 38 recipes, 8 condition guides, and the usual hand-written ones. 516 URLs in the sitemap.

Every one of those pages has a title tag and a meta description, and the thing about those two fields is that they are not copy, they are copy with a budget. Both get cut, and on these pages the tail is the part that carries the answer. The comment on the limits says it:

 * A title of 60 characters or more, or a description of 160 or more, gets cut * mid-word, and the part that gets cut is usually the part carrying the answer. * These are the budget every page is written to, enforced by * lib/__tests__/seo-meta.test.ts over the static pages and the generated ones * alike. Both are exclusive: a title must be shorter than TITLE_LIMIT. 
export const TITLE_LIMIT = 60; export const DESCRIPTION_LIMIT = 160; 

This post is about what happens when the budget binds, which turns out to be measurable down to the single character.

Twelve of the sixty are spent on the brand

The root layout appends the brand to every page title through Next's title.template, which costs twelve characters:

export const TITLE_SUFFIX = ` | ${SITE_NAME}`; 

One fifth of the budget, before the page has said anything. On most pages that is a fine trade: a search result with a brand in it gets clicked more by people who have heard of you. On a page whose title has to be the question a person typed, it is twelve characters of competition.

So the suffix is the part that gives way:

/** * A page title with the brand suffix accounted for. * * The suffix is worth twelve characters of a sixty-character budget, and on the * longest titles here (the question pages, whose title tag has to be the * question itself) that is the difference between a whole title and a truncated * one. Where it does not fit, the suffix is dropped rather than the subject: * a reader scanning results is matching the question, and the brand is already * in the URL and the breadcrumb beside it. */ function resolvedTitle(title: string): string | { absolute: string } { return title.length + TITLE_SUFFIX.length < TITLE_LIMIT ? title : { absolute: title }; } 

Returning { absolute } is how you tell Next to ignore the template for this page. The branch is three lines and the decision in it is the interesting part: when a title and the brand cannot both fit, keep the words that match the query and throw away the ones that are already in the URL, the breadcrumb, the favicon and the domain.

Two pages out of 507

I ran the rule over everything generated: 461 answer pages, 38 recipes, 8 condition guides. Exactly two of them lose the brand.

One is the longest recipe title on the site, at 55 characters:

Pork loin with rosemary potatoes and green beans recipe 

The other is an answer page at 48 characters, and 48 is the whole story:

Is fully hydrogenated vegetable oil OK with BAM? 

48 plus 12 is 60, and the comparison is < TITLE_LIMIT, strictly. One character shorter and this page would carry the brand. The next longest answer page is 44 characters and does carry it, at 56 total.

Both are live and checkable in a browser tab or with curl:

$ curl -s https://munchable.app/is-fully-hydrogenated-vegetable-oil-ok-with-bam | grep -o '<title>.*</title>' <title>Is fully hydrogenated vegetable oil OK with BAM?</title> $ curl -s https://munchable.app/is-hydrogenated-palm-kernel-oil-ok-with-ibd | grep -o '<title>.*</title>' <title>Is hydrogenated palm kernel oil OK with IBD? | Munchable</title> 

Two sibling pages, same shape, same template, four characters of ingredient name between them, and one of them is unbranded in every search result it ever appears in. That is what a hard budget looks like from the inside: not a gradual degradation, a cliff that one page happens to be standing on.

The thing the rule has no opinion about

Here is what the budget does not catch:

$ curl -s https://munchable.app/about | grep -o '<title>.*</title>' <title>About Munchable | Munchable</title> 

27 characters, comfortably inside the budget, and the brand is in there twice. The rule asks whether the title fits. It has no opinion about whether the title already contains the thing being appended.

What makes that one annoying rather than merely imperfect is that the repository already has the fix, used on exactly one page:

// Absolute: the homepage title is the brand line itself, so the layout's // "%s | Munchable" template would otherwise double the name. title: { absolute: 'Munchable | Trust your gut again' }, 

The mechanism exists, the reasoning is written down, and it was applied where the doubling was ugliest and not where it is merely redundant. Which is the normal way a repository ends up inconsistent: not through ignorance of the fix but through a judgement about how much the problem costs, made once, in one file, by someone who was looking at a different page.

The descriptions are closer to the edge than the titles

Titles have slack. Descriptions do not: the longest on the site is 157 of 160, and thirteen of them sit within five characters of the limit.

That is not carelessness, it is the shape of generated copy. These descriptions are assembled from an ingredient name, a condition, and a sentence pattern, so their length is set by the data rather than by an author. The recipe adapter has the same problem and handles it by dropping a clause instead of truncating:

 * The clause is the optional tail and it is dropped rather than truncated: a * description cut mid-list ends on "except gastroparesis and" and tells the * reader less than the sentence without it. It got too long by two words when * the eighth condition landed, which is the shape of change that will happen * again. 

"Got too long by two words when the eighth condition landed" is the failure mode for every generated description on the site. Nobody edits a page and breaks it. The data grows a longer member and 38 pages move at once.

The test for the generated half

Which is exactly why the test refuses to sample:

it('fits the snippet on every question page', () => { // The whole set, not a sample: the longest ingredient name decides this and // there is no way to know which one it is from here. assert.ok(ANSWER_PAGES.length > 100, 'expected the full question page set'); for (const ref of ANSWER_PAGES) { const page = answerPage(ref.question); assert.ok(page, `/${ref.question}: no page`); checkTitle(`/${ref.question}`, page.ask); checkDescription(`/${ref.question}`, page.metaDescription); } }); 

The guard on the collection's own size is the part I would not have written first. Without it, a change that leaves ANSWER_PAGES empty turns this test into a loop over nothing, which passes, and the suite reports green for a site that no longer has any pages. Any test that iterates a generated collection needs one assertion about the collection before the loop.

It also calls answerPage(), the same function the route calls, rather than reimplementing the string building. The only thing it reimplements is the suffix rule, and it says so:

/** The title as it reaches the browser: app/layout.tsx appends the brand. */ function renderedTitle(title: string): string { // Mirrors resolvedTitle() in lib/seo.ts, which drops the suffix rather than // the subject when the two together will not fit. return title.length + TITLE_SUFFIX.length < TITLE_LIMIT ? title + TITLE_SUFFIX : title; } 

Two implementations of one rule, in two files, with a comment admitting it. More on that below, because it is the real defect in here.

The test for the hand-written half reads the pages as text

The other half of the site is hand-written pages, and the test gets at their metadata in a way I did not expect to end up defending:

 * The hand-written pages, read out of app/ so that adding a page adds a check. * * Source text rather than an import, because these are server components: their * modules pull in React, `next/navigation` and the database client, none of * which will load under `node --test`. Only string literals are read, so a page * whose metadata comes from a constant is covered by the constant above * instead, and a page with no literal metadata at all is skipped rather than * silently passed: ROUTES_WITHOUT_LITERAL_META names those, so the skip stays * deliberate. 

So it walks app/, finds the 31 page.tsx, layout.tsx and not-found.tsx files, and pulls the strings out with a regex:

const start = source.search(/export (?:const metadata|async function generateMetadata)/); if (start === -1) return; const block = source.slice(start, start + 1200); const title = literal(block, 'title'); const description = literal(block, 'description'); 

The 1200 character window exists because the regex would otherwise happily match a title: on some unrelated object further down a 300 line page. The literal() helper knows about the one bit of structure it has to see through, title: { absolute: '...' }, and the check for the homepage passes a flag saying the title owns the whole tag, brand included.

Reading source with a regex is the kind of thing that gets a comment in review, and the alternative was worse. Importing a server component under a plain node --test run means pulling in React, the router and a database client to read two strings. Spinning up a Next build to assert a string length puts a sixty second step in front of a test that currently runs in milliseconds. The thing that makes the regex approach survivable is the explicit allowlist of routes with no literal metadata: eight entries, each with a comment saying where it is covered instead, so a page that silently stops being checked fails rather than passes.

That is the rule I would take from this whole file. A test that skips things must know what it skips.

What is still wrong with this

  • The suffix rule exists twice. resolvedTitle in production and renderedTitle in the test. If one changed to truncate instead of dropping, the other would keep agreeing with itself and the suite would stay green. The fix is to export the production function and have the test call it, and the only reason it has not happened is that the duplicate is three lines and reads as obviously correct.
  • Nothing checks the deployed HTML. Everything here is a model of what Next will emit from what the code says. The two titles I quoted from the live site were checked by hand, by me, writing this post. No test fetches a page.
  • The limits are characters, and the cut is pixels. 60 and 160 are approximations of a width in a search result, and a title of 60 narrow characters survives where one of 55 wide ones does not. The budget is a useful fiction, and it is the best fiction available from a unit test.
  • The brand can still appear twice. /about ships "About Munchable | Munchable" and the test is satisfied, because the rule it enforces is about length.
  • One description still says the wrong number. The homepage's own description ends "Seven gut conditions, one scan" and the product has covered eight for some time. Every length check on it passes. I wrote about that one when I found it, and it is still there, which tells you what the character budget is and is not protecting.

Check the two pages yourself

The two unbranded titles are the BAM answer page and the longest recipe. Compare them against a sibling that fits, such as the IBD page for a four-character-shorter ingredient or any of the condition guides, where the title lands at 40 characters with the brand attached. The duplicated one is on the about page, and the whole generated set is reachable from the answers index and the recipe index. Browser tabs show it without curl.

Related: 461 of our URLs are one dynamic segment at the site root, which is how those pages are served, and five URLs our test suite demands we never publish, which is the neighbouring guarantee about the same set of pages.