My pension calculator has two language versions, English at / and Hindi at /hi/, and most pages exist twice. I had always treated hreflang as a tag you paste once and forget. The problem is that it fails silently: nothing breaks in the browser, and search engines simply ignore alternate tags that don't agree with each other. So today I wrote a checker instead of trusting my build script.
What the site generates
hreflang is a reciprocal contract. If the English page says "my Hindi twin is here", the Hindi page has to say the same about the English one, and each page should list itself. Both versions get their block from one place in tools/build.py:
en_u, hi_u = (cp, path) if lang == "hi" else (path, cp)
alt = (f'<link rel="alternate" hreflang="en-IN" href="{DOMAIN}{en_u}">'
f'<link rel="alternate" hreflang="hi-IN" href="{DOMAIN}{hi_u}">'
f'<link rel="alternate" hreflang="x-default" href="{DOMAIN}{en_u}">')
cp is the path of the counterpart page. The first line swaps the order depending on which language is being rendered, so the same three links end up in both files. I use en-IN and hi-IN because the audience is Indian; plain en and hi would also be valid. Mixing the styles between twins is what you want to avoid. x-default points to English, which is a judgment call on my part.
Why generating it from one function still isn't enough
The function can be right and the output still wrong. My vercel.json has "trailingSlash": true, so the canonical form of a page is /hi/life-certificate/. If an alternate link ever came out without the slash, it would point at a redirect, not at the canonical URL. A few hand-written pages also bypass the generator. And a counterpart path that has a typo is just a link to a 404.
So the script checks four things for every index.html that carries hreflang:
const canon = (html.match(/rel="canonical" href="([^"]+)"/) || [])[1];
const alts = [...html.matchAll(/rel="alternate" hreflang="([^"]+)" href="([^"]+)"/g)];
if (!canon.endsWith('/')) fail(f, 'canonical without slash');
if (!alts.some(a => a[2] === canon)) fail(f, 'page does not list itself');
for (const [, code, url] of alts) {
if (!url.endsWith('/')) fail(f, 'no trailing slash ' + url);
const file = '.' + url.replace(DOMAIN, '') + 'index.html';
if (!fs.existsSync(file)) fail(f, 'target missing ' + url);
else if (code !== 'x-default') {
const other = fs.readFileSync(file, 'utf8');
if (!other.includes('href="' + canon + '"')) fail(f, 'no return link from ' + url);
}
}
The last check is the one that matters most. It opens the page that the alternate points at and confirms that it links back. Without a return link, Google treats the pair as unconfirmed.
What it found
I ran it against the built site: 76 pages carry hreflang, and 0 problems came back. I'll be straight about that. I half expected to find something, and I didn't. The value is that the next time I add a hand-written page or change the URL scheme, this fails loudly on my machine instead of three weeks later in Search Console. A checker that has never failed is not proof it works, so my next step is to break a page on purpose and confirm it gets flagged.
The language switcher is part of this
The visible link that moves a reader to the other language carries the same attributes:
<a href="/hi/life-certificate/" lang="hi" hreflang="hi-IN">हिंदी</a>
lang="hi" matters for accessibility. Without it a screen reader may try to read Devanagari using English pronunciation rules.
Take-aways
- Generate the alternate block from one function so both twins always agree.
- Verify the built output, not the generator, because that's what crawlers see.
- Check the return link, not only that the target exists.
- Make the check fail the build, or you will stop running it.
I haven't measured any traffic effect from this, and I won't pretend to. If you want to see the output, view source on any page of the 8th Pay Commission pension calculator and look in the head.
If you test hreflang on your own site, what do you use? I'm curious whether there's a better tool than my script.
Top comments (0)