Munchable's website is mostly one shape of page repeated: a question about one ingredient and one gut condition, answered. There are 461 of them. Last week I added a page that is not that shape at all, a flat list: hidden gluten on a food label. It has 88 words on it in three groups, and writing it taught me something about when a long tail is the wrong answer.
A list is a different question, not a bigger one
The one-question pages are built for the search they match. Somebody types "is oat gluten free" and lands on exactly that page, which answers it and stops.
Then look at what people type when they are not asking about a specific food: "hidden gluten", "what ingredients contain gluten", "gluten free ingredient list". Those are not questions about an ingredient. They are requests for the vocabulary, and no page about one word can serve them. You can feel the mismatch if you imagine trying: a reader who wanted the list arrives on a page about oats, reads a paragraph about oats, and leaves to go and find the list.
The long tail and the head term want different pages, and the second one is a page type you may not have in your system, because it is not generated per row. It is one page whose body is the rows.
The list is derived, including the numbers in the headings
The thing that makes this page cheap to keep is that nothing on it is maintained. The groups are a filter over the ingredient index, the headings carry a count taken from the group's own length, and the total in the opening paragraph is a reduce:
export const GLUTEN_WORD_GROUPS: GlutenWordGroup[] = (
['gluten', 'uncertain', 'free'] as const
).map((status) => ({
status,
...BLURBS[status],
words: INGREDIENTS.filter((entry) => entry.coeliac === status),
}));
/** How many label words the page covers, counted rather than written down. */
export const GLUTEN_WORD_COUNT = GLUTEN_WORD_GROUPS.reduce((n, g) => n + g.words.length, 0);
Today that renders as 68, 13 and 7, totalling 88, and the only reason I know those numbers is that I just loaded the page. Add a word to the data and the row appears, its group heading increments, and the sentence at the top of the page changes with it.
The one piece of standing editorial text on the page is the three blurbs, one per group, explaining what a group means. Those are prose because a reader needs them to be, and they are the one thing on the page I would have to update by hand if the meaning of a group changed, which is a fair price.
There is one more derived string worth calling out, because it is the kind of thing that goes wrong quietly. The page ends on the standing line the product shows about the limits of what it can tell you. That line is not retyped on the page: it is read off a real call into the engine:
export const COELIAC_BANNER: string = (() => {
const result = fitCheck(/* a minimal probe product */, { conditions: ['coeliac'] });
return result.perCondition[0]?.banner ?? '';
})();
A reference page that stated the product's limits more softly than the product does would be the one place a reader met the optimistic version. Reading the sentence rather than copying it means there is no optimistic version to meet.
Linking down is easy, linking back up is a decision
Every row on the list links to that word's own question page, so the list is an entry point into the long tail. 88 links out, which is the entire reason a page like this earns its place in a site structure: pages that are only linked from a sitemap get crawled slowly and ranked poorly.
The link in the other direction took more thought. A question page should mention the list, but only if its condition has one, and seven of the eight conditions do not. So the pointer lives on the condition's own content entry as an optional field, and the page renders the sentence only when it is set:
{guide.furtherReading?.map((item) => (
<span key={item.href}> For every word at once, there is <Link href={item.href}>{item.inline}</Link>.</span>
))}
An optional field with one user looks like over-engineering until you consider the alternative, which is a condition check in a template. The field is also where the anchor text lives, so the sentence reads as part of the paragraph it sits in rather than as a module bolted on. (I wrote about the content array those entries live in here.)
The breadcrumb points sideways, and the two breadcrumbs disagree
The page's URL has no folder in it. It is /hidden-gluten-ingredients, a top-level path, because that is the shortest honest URL for a reference page. Its visible breadcrumb says something else:
Coeliac disease / Hidden gluten
That is a lie about the filesystem and the truth about the reader: the page only makes sense as part of one condition, and a crumb that said "Home / Hidden gluten" would be describing a directory structure to somebody who does not have one.
Having written that sentence, I went and read the structured data on the same page, and its BreadcrumbList says Home / Conditions, which is neither of the above. Three different ideas of where this page sits, in one page. The visible crumb is the considered one, the JSON-LD does not match it, and a crawler that compares the two gets a trail that no link on the page corresponds to. That is a two-line fix I will make when I next touch the file, and I would rather write it down than quietly correct it before posting.
The pages that are deliberately not generated
The question pages come from the same word list, and some of those words cannot have a page, because the question would answer itself. The vocabulary contains the word "gluten", and labels print it, so the index holds it. But /is-gluten-gluten-free is not a page anybody should be able to reach. Nor are the ones where the label phrase already contains a free-from claim.
That exclusion is asserted rather than assumed:
it('the words that are their own answer get no page at all', () => {
const slugs = new Set(ANSWER_PAGES.map((p) => p.question));
for (const slug of [
'is-gluten-gluten-free',
'is-cereals-containing-gluten-gluten-free',
'is-gluten-free-oats-gluten-free',
'is-gluten-free-wheat-fiber-gluten-free',
]) {
assert.equal(slugs.has(slug), false, `${slug} should not be published`);
}
});
A generated page set has no editor, so every page you have decided not to publish is a decision that lives in code and nowhere else. Writing the four slugs out as literals in a test is the cheapest possible record of it: the next person who widens the generation rule finds out in 300 milliseconds that they have just published /is-gluten-gluten-free.
Have a look
The list is here, the condition it belongs to is here, and the long tail it links into starts from the answers index. The three group headings are the quickest way to see the derivation working: the numbers in brackets are not in the source of that page.
Top comments (0)