DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our interview guide cluster went from 5 pages to 14, and the new code is a three-value union

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' },
];
Enter fullscreen mode Exit fullscreen mode

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>
))}
Enter fullscreen mode Exit fullscreen mode

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 tips on both the video-interview and the HireVue page
  • tell me about yourself answer on both the common-questions and the tell-me-about-yourself page
  • structured interview questions on 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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)