CogniPrep is practice for the psychometric tests and game based assessments employers send candidates. The library covers 56 assessment providers, each with its own set of practice games, and the whole catalogue lives in one module: lib/games/constants.ts, about 4,400 lines of game metadata, provider metadata and lookup helpers.
That module is fine on the server. The problem was a component that needed almost none of it.
The component
EntryIntentTracker records first touch intent: which provider a visitor came for. It is a Client Component, because it watches usePathname() and re-checks on client side navigation, and it is mounted once in the root layout:
// app/layout.tsx
<EntryIntentTracker />
Its job is three signals, in priority order: ?provider=<slug> on any page, the assessment centre landing page, and a provider page at /games/<provider>. Plus one more case that turned out to be expensive: a specific game at /games/<gameId>, mapped back to its provider.
That last mapping is why the component imported the game catalogue:
import { GAME_LIBRARY } from '@/lib/games/constants';
A Client Component in the root layout is the worst possible place to put an import. Whatever it pulls in lands in the first load JavaScript of every page on the site. So a visitor reading a guide on the blog, or sitting on the sign in form, was downloading roughly 100 KB of metadata about games they were not playing.
Fix one: the slugs get their own module
The slug check needs exactly one thing: the list of provider slugs. So the list moved into a file that holds the slugs and nothing else, lib/games/provider-ids.ts, and the heavy module re-exports it:
// lib/games/constants.ts
export { ALL_PROVIDERS, PROVIDER_COUNT, isAssessmentProvider } from './provider-ids';
Every server side caller keeps importing from constants. The client side callers import the small module directly and get a few dozen strings.
Fix two: the lookup that could not be made small
The game id case does not shrink. Mapping pm-digits back to pymetrics needs the catalogue, by definition.
It got deleted instead. The game route is a Server Component, and it has already looked the game up in order to render it, so the provider is sitting right there:
// app/games/[game-name]/page.tsx
<EntryIntentRecorder provider={game.provider} />
EntryIntentRecorder is a tiny Client Component that takes a resolved slug as a prop and records it. The lookup happens where the answer is already known for free, and nothing about the catalogue crosses the network. This is the same server resolves it, client records it split that the employer guide pages already used, so it was a pattern to copy rather than invent.
Why the list is an object, not an array
The obvious shape for a list of slugs is an array. It is not what the file uses:
const PROVIDER_IDS: Record<AssessmentProvider, true> = {
'arctic-shores': true,
hirevue: true,
usps: true,
// ... 53 more
};
export const ALL_PROVIDERS = Object.keys(PROVIDER_IDS) as AssessmentProvider[];
export const PROVIDER_COUNT = ALL_PROVIDERS.length;
export function isAssessmentProvider(value: string): value is AssessmentProvider {
return Object.prototype.hasOwnProperty.call(PROVIDER_IDS, value);
}
Record<AssessmentProvider, true> forces the object to name every member of the AssessmentProvider union. Adding a provider to the type and forgetting this file is a compile error. With an array it would be a slug that silently stops being recognised in URLs.
hasOwnProperty rather than value in PROVIDER_IDS for a reason the tests spell out: 'toString' in obj is true. Two of the test cases are isAssessmentProvider('toString') and isAssessmentProvider('constructor'), both expected false.
The thing that makes the split safe
Deriving ALL_PROVIDERS from PROVIDER_META was self maintaining. Two hand written lists are not, so the tests became the mechanism:
expect(ALL_PROVIDERS).toEqual(Object.keys(PROVIDER_META));
expect(ALL_PROVIDERS.join(',')).toBe(Object.keys(PROVIDER_META).join(','));
The second line looks redundant next to the first, and it is, because toEqual on arrays already compares order. It is there as a statement of intent: the key order is the display order of the provider grid on the landing page, so it must never be "fixed" by sorting one side. A test that passes for the wrong reason is still a comment that cannot rot.
Except for the comments. The doc block in provider-ids.ts says the split "lets those callers import 24 strings instead". It was 24 when the file was written. It is 56 now, and the only reason the number in the product copy is right is that it is interpolated from PROVIDER_COUNT rather than typed out.
What I would take from this
The root layout is a budget, and in the App Router it is spent by imports rather than by code you can see on the page. Before adding an import to a Client Component that lives there, the question is not "is this module expensive" but "is the expensive thing it reaches needed by every page on the site".
The products those 100 KB describe are at cogniprep.app/games, and the provider pages are where the catalogue actually earns its weight, with a card per game: Arctic Shores has 14 of them.
Top comments (0)