CogniPrep is in English. Last week it got fifteen Dutch pages, at /nl and beneath it: one hub, nine pages about Dutch assessment publishers, and five about test formats under their Dutch names. The product behind them is still in English, which turns out to be the whole design problem.
Two parts of this are worth writing down: getting hreflang right in both directions, and stopping a localised cluster from quietly over-promising.
hreflang is a pair, so the test checks both ends
Nine of the fifteen pages are the Dutch counterpart of an existing English page: /nl/politie pairs with /games/politie. Those nine declare three alternates:
export function nlLanguages(page: NlPage): Record<string, string> { const path = nlPath(page.slug); return page.englishTwin ? { en: page.englishTwin, nl: path, 'x-default': page.englishTwin } : { nl: path }; } The six pages with no English twin (the hub and the five format pages) declare only nl. The comment above the function is the reasoning, and it is the bit people get wrong:
A page without a twin declares only itself as
nl: there is no English equivalent to send anyone to, and anx-defaulton a Dutch-only page would tell search engines to show Dutch to everyone else.
Search engines ignore hreflang that is not reciprocal, which means the interesting failure is not a typo in this function. It is somebody adding a Dutch page, pointing it at an English page, and never editing the English page. So the test goes and reads that file:
const english = readFileSync(join(APP, 'games', page.provider, 'page.tsx'), 'utf8'); expect(english, page.provider).toContain(`nl: '/nl/${page.provider}'`); expect(english, page.provider).toContain(`en: '/games/${page.provider}'`); Asserting on the source text of another page is crude, and it is the only thing here that actually catches the real mistake. Importing the English page's metadata would not: generateMetadata and module-level metadata exports are not reliably readable from a test, and a page that builds its alternates from a shared helper would pass while rendering nothing. The string is what ships.
A matching test asserts the inverse, that no twinless page has sneaked an x-default in, and a third one asserts openGraph.locale is nl_NL and the canonical is the page's own path, for all fifteen.
One trap if you go looking in the HTML. Grep a rendered page for hreflang= and you will find nothing. React serialises the attribute as written in JSX, so what ships is hrefLang="nl". HTML attribute names are case-insensitive, so it parses exactly as intended and every validator is happy, but your grep has to be case-insensitive.
The harder half: not over-promising
A Dutch page about a Dutch test, on a site whose practice material is in English, is one careless sentence away from a false promise. Four tests exist to make that sentence impossible to merge.
Every page carries a language box, and the box has to mention Engels:
it('says on every page, in its language box, that the practice is in English', () => { for (const page of NL_PAGES) { expect(page.language.heading, nlPath(page.slug)).toMatch(/Engels/); } }); Then a negative test, which is the one I like, scanning every string in the registry for a claim nobody should be able to write:
it('never claims a Dutch-language practice test exists', () => { const claims = strings(NL_PAGES).filter((s) => /(onze|bij ons)[^.]*\b(nederlandstalige|in het nederlands)\b[^.]*\boefen/i.test(s) ); expect(claims).toEqual([]); }); The hub does not hedge about it either. Its language box says, in Dutch, that the explanation is in Dutch, all practice tests are in English, and CogniPrep has no Dutch-language practice test. Saying that above the fold on a page competing for Dutch search traffic costs conversions. Not saying it costs refunds and trust, which is worse.
The third test asserts no two pages open the same way, comparing the first five words of every intro and lead paragraph, because fifteen pages written to one template by one worker in one sitting will otherwise all start "Als je bent uitgenodigd voor". The fourth asserts every practice test named on a format page exists and is implemented, and that every one of the nine publishers has at least one free test, since the pages say so.
Small decisions worth stealing
- Two pieces of inline markup, no more. Copy strings allow
**bold**and[label](/path). A 19-line module holds the regexes,stripInline()for structured data (so JSON-LD never carries asterisks) andinlineHrefs()so a test can pull every link out of every paragraph and check it resolves. 50 internal links across the cluster, all verified against the filesystem. - No tables. The registry type has a
rowsfield for term and explanation pairs, with the reason in the comment: "rendered as a definition list (no tables: 320px)". A two-column table of Dutch test names at 320 pixels is either clipped or a horizontal scroller. - The footer link carries both attributes.
lang="nl"andhrefLang="nl"on the one "Nederlands" link, because the first tells a screen reader to switch pronunciation and the second tells a crawler what it will find.
Fifteen pages, 49 FAQs, about 13,000 words of Dutch copy, and the thing I would check first on any similar cluster is the twin's source file.
See it
Open cogniprep.app/nl/politie and view source, then do the same on cogniprep.app/games/politie. Both carry the same three <link rel="alternate" hrefLang="..."> lines, pointing at each other, with x-default on the English page. Then check cogniprep.app/nl: one alternate, nl, no x-default, because that page has no English equivalent.
While you are on the hub, read the highlighted box headed "Deze uitleg is in het Nederlands". It is the sentence that tells a Dutch visitor our practice tests are not in their language, in their language.


