This week we shipped a hub page for one search intent: people who have been invited to something that looks like a game rather than a test. It is one route folder, app/game-based-assessments/, holding a 503-line page and a 236-line data file. The thing worth writing about is the line the data file opens with:
Copy lives here; product facts do not.
Every sentence of editorial on that page is typed by a human. Every number, name, duration and classification is read out of the product at build time. Here is what that actually buys, and the one place it cannot help.
Nine vendors, two of them open-ended
export interface GameBasedVendor { provider: AssessmentProvider; id: string; // anchor on the hub page productNames: string; // what the invitation is likely to call it games: string[] | 'all'; // the game-based part of the suite measures: string; scoring: string; prepare: string; guideSlug: string; // the blog guide for this vendor } The games field is the interesting one. Some vendors sell nothing but game-based assessments, so their entry says 'all' and a game added to that suite appears on the hub by itself. Others sell a mixed suite where only part of it is game-shaped, so those entries name ids:
| Vendor entry | games | Resolved today |
|---|---|---|
| Arctic Shores | 13 ids | 13 |
| Pymetrics | 'all' | 12 |
| Aon / cut-e | 4 ids | 4 |
| HireVue | 8 ids | 8 |
| McKinsey Solve | 'all' | 6 |
| Revelian | 7 ids | 7 |
| Test Partnership | 3 ids | 3 |
| AssessFirst | 2 ids | 2 |
| Cappfinity | 2 ids | 2 |
57 in total, and the page's own opening section says so:
Of the 57 games on this page, 50 have a correct answer or a better score ... The other 7 describe you.
Those three numbers are not typed. They are three reduce calls over the resolved cards:
const totalGames = vendors.reduce((sum, { games }) => sum + games.length, 0); const traitGames = vendors.reduce((sum, { games }) => sum + games.filter((g) => g.trait).length, 0); const freeGames = vendors.reduce((sum, { games }) => sum + games.filter((g) => g.freeTier).length, 0); trait and freeTier are both read off the library entry, so the paragraph about which games have a right answer and the sentence about how many you can play for free are the same data as the markers in the lists below them. Ship a tenth vendor tomorrow and the prose rewrites itself.
Three different ways it refuses to be wrong
Each card is assembled from four reads, and three of them throw rather than degrade.
A renamed id fails the build.
const game = getGameById(id); if (!game || game.provider !== vendor.provider) { throw new Error( `Game "${id}" listed for ${vendor.provider} on /game-based-assessments is not in GAME_LIBRARY under that provider.` ); } Note the second half of the condition. An id that exists but has moved to another provider is also an error, because a card that quietly lists a competitor's game is worse than a build failure.
A missing guide fails the build. Each card links one blog guide by slug, through requireBlogPost(slug), which throws with the name of the file you need to edit. The alternative, a getBlogPost returning undefined, gives you a card with a dead "read the guide" link and a green build.
A retired game drops silently, on purpose.
return entries.filter((game) => game.implemented !== false).map(toResolved); That one is deliberately not an error. A game pulled from the catalogue should disappear from the hub quietly; a game renamed should stop the deploy. Same file, opposite policies, because the two mistakes have different costs.
The fourth read is the vendor label, which comes from the same provider metadata the header, the hubs and the search box use, so the hub cannot call Revelian something the rest of the site does not.
The cap it cannot fill
Each card carries a strip of employers evidenced to use that vendor, from the same employer-to-provider file that drives our employer guides:
employers: EMPLOYER_GUIDES.filter((guide) => guide.providers.includes(vendor.provider)) .slice(0, EMPLOYERS_PER_VENDOR) // 4 Across the nine cards that cap is reached six times and missed three. McKinsey Solve shows one employer, because the product is used by one firm. Test Partnership shows one and AssessFirst three, because that is how many employers we have a citable source for. The live page carries 29 contextual employer links and the slot for 36.
A cap that cannot be filled is the correct output here. The file behind it only accepts an association that came with a URL someone opened, so a half-empty strip is the page telling the truth about our evidence rather than about the vendor's customer list.
And no links to the games themselves
The page names 57 games. It links to zero of them.
Every game route is behind authentication, so a visitor who clicks one gets a sign-in form and a crawler gets a shell. Our own robots.txt carries one Disallow line per game for exactly that reason. So the hub links the nine provider hubs instead, which are the indexable pages where those games are described, plus the blog guide per vendor and the employer strip above.
That is the rule I would take to any content page over an auth-gated product: name the thing, link the page about the thing.
See it
Open the hub and compare two things on it.
- The "Does the game have a right answer?" paragraph, and the per-card game lists. The 57 / 50 / 7 split in the prose is the same data as the markers in the lists, because it is one array read twice.
- The anchors. View source, search
href="/games, and you get twelve hits: the nine provider hubs, plus three to the/gamesindex. Searchhref="/games/with a trailing slash plus a game id and you get none.
Then open the Arctic Shores hub it sends you to, which is where the 13 games on that card actually live.

