Our interview product has a hub at /interview and a cluster of guide pages under it. Until last night there were five of them: video interviews, common questions, competency, strengths, HireVue. The hub rendered them as one curated list, deliberately not alphabetical, with a comment in the registry saying the two pages answering the broadest intent lead.
Then we shipped nine more, because the product grew a second practice track for experienced hires and returners, and the searches those candidates make are nothing like a graduate's. Fourteen cards in one flat list is not a list any more. It is a wall.
The change is one field
The two guide clusters on the site share a metadata type, GuideMeta in lib/guides/types.ts, used by /interview/<slug> and /assessment-centre-practice/<slug>. The assessment centre cluster did not need grouping, so the group did not go on the shared type:
export type InterviewGuideGroup = 'any' | 'early_careers' | 'experienced'; export interface InterviewGuide extends GuideMeta { group: InterviewGuideGroup; } export const INTERVIEW_GUIDE_GROUPS: { id: InterviewGuideGroup; heading: string }[] = [ { id: 'any', heading: 'Questions in almost every interview' }, { id: 'early_careers', heading: 'Video, graduate and early-career interviews' }, { id: 'experienced', heading: 'Experienced hires, managers and returners' }, ]; A union rather than a string, so a typo in the fifteenth guide is a compile error rather than a card that silently renders nowhere. The headings array is also the order the hub uses, so a group cannot exist without a heading and the display order lives in one place instead of in the page.
The hub then does the obvious thing, once:
{INTERVIEW_GUIDE_GROUPS.map(({ id, heading }) => ( <section key={id} aria-labelledby={`guides-${id}`}> <h3 id={`guides-${id}`}>{heading}</h3> ... {INTERVIEW_GUIDES.filter((guide) => guide.group === id).map(...)} </section> ))} Three sections, each labelled by its own heading, which is the part that makes the grouping real for a screen reader rather than just visual. Three of the fourteen pages are in any, four in early_careers, seven in experienced.
What came along for free, and what did not
The registry is the source for the sitemap, so the nine new pages and the new lastModified arrived there without anyone touching the sitemap except to change one date: the /interview entries moved from 2026-09-16 to 2026-11-01. The live sitemap is now 386 URLs, 15 of them under /interview.
What did not come along for free:
- The footer. It has a hand-written column of links, so nine new pages bought exactly one new entry in it, chosen by hand.
- The sibling strip on each guide page. The hub groups the fourteen; each individual page still links all thirteen others in registry order, ungrouped. We grouped the hub and stopped there, which is a thing worth writing down rather than pretending was a decision.
The numbers the review actually turned up
Fourteen guides carry 161 keyword strings, of which 158 are distinct. The three repeats are the interesting part, because two sibling pages competing for the same phrase is the one failure mode a cluster has:
-
hirevue interview tipson both the video-interview and the HireVue page -
tell me about yourself answeron both the common-questions and the tell-me-about-yourself page -
structured interview questionson both the competency and the experienced-hire page
Meta titles run 29 to 46 characters against the type's own documented ceiling of 48, which exists because the root layout appends | CogniPrep and the rendered title has to stay under 60. Descriptions run 144 to 159 against a limit of 160. Nobody has ever had to fix one of those, because the rule is a comment on the field rather than folklore.
The nine new pages are 2,775 to 3,097 words of rendered text; the five older ones are 1,609 to 2,141. That gap is not a quality signal, it is a scope signal: the newer intents ("why did you leave your last job") want worked examples at several levels of seniority, and the older ones ("strengths-based interview") want a format explained once.
Two blog articles landed in the same commit, and they are where the hand-typed numbers are. The registry's convention is that readTime is wordCount / 200. One entry declares 1,400 words and 7 minutes, which fits. The other declares 1,750 words and 8 minutes, and 1,750 / 200 is 8.75. The rendered article is 2,277 words. Both of those fields are estimates typed by a human next to a body written in JSX, which is exactly why they drift.
One measurement trap
If you grep the live HTML of a Next.js app server component for a string and get exactly twice what you expected, you are counting the inlined flight payload as well as the markup. Strip <script> blocks before counting anything:
curl -s https://cogniprep.app/interview \ | perl -0pe 's/<script.*?<\/script>//gs' \ | grep -o 'href="/interview/[a-z-]*"' | sort | uniq -c That gives 19 links to 14 distinct guides on the hub: five appear twice. Two of those are the footer's own hand-written interview links, which are on every page. The other three are in-prose links inside the paragraph that explains who the experienced track is for, which is the kind of link that is worth more than a card.
See it
- The grouped hub: cogniprep.app/interview. View source and search for
aria-labelledby="guides-to find the three sections, or run thecurlabove to count the cards per group. - One of the new pages: How to answer "Tell me about yourself", and one from the new group: Interview questions for experienced candidates.
- The derivation: cogniprep.app/sitemap.xml, where every
/interview/entry carries2026-11-01because one line inapp/sitemap.tsmaps over the registry. - The keyword overlap: open the HireVue guide and the video interview guide and compare their
<meta name="keywords">tags. The shared string is still there as of writing.