How to validate hreflang tags
Fetch every URL the set names, then build the matrix: node hreflang-matrix.mjs <url> prints which page names which, and flags any pair where only one side names the other. Reciprocity is the rule an engine enforces, and a one-way pair is dropped without a message anywhere.
Checker offline. Follow the manual steps below, they give the same answer.
Why check this
Run this before a locale launch and after any release that adds, renames or retires a language version. An annotation counts only when the page it names names it back, so the unit you validate is the pair, and seeing one means fetching both.
The failure it prevents: a release adds a Spanish version and links it from every locale, but the German template is not rebuilt. Spanish names German, German never names Spanish, and that pair is ignored.
Prerequisites
- Node 22. The crawler uses
fetchand the built-inURLparser. - curl 7.x or later for step 1.
- Google's page on localized versions of a page, which states the return link requirement. Other engines differ in whether they read hreflang at all.
- The values are checked separately. This procedure assumes they pass.
Steps
- Step 1.
Start a four locale set with three planted defects. Save it as
hreflang-set.jsand run it. The German page omits Spanish, the French canonical points at English, the Spanish page isnoindex.const http = require('node:http'); const B = 'http://127.0.0.1:8942'; const u = { en: `${B}/en/`, de: `${B}/de/`, fr: `${B}/fr/`, es: `${B}/es/` }; const pages = { '/en/': { set: ['x-default:en', 'en:en', 'de:de', 'fr:fr', 'es:es'], canonical: u.en }, '/de/': { set: ['en:en', 'de:de', 'fr:fr'], canonical: u.de }, '/fr/': { set: ['en:en', 'de:de', 'fr:fr', 'es:es'], canonical: u.en }, '/es/': { set: ['en:en', 'de:de', 'fr:fr', 'es:es'], canonical: u.es, noindex: true }, }; http .createServer((req, res) => { const p = pages[req.url]; if (!p) { res.writeHead(404, { 'content-type': 'text/html' }); res.end('<title>404</title>'); return; } const links = p.set.map((s) => s.split(':')) .map(([v, k]) => ` <link rel="alternate" hreflang="${v}" href="${u[k]}" />`).join('\n'); const robots = p.noindex ? '\n <meta name="robots" content="noindex,follow" />' : ''; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(`<!doctype html><html><head>\n${links}\n <link rel="canonical" href="${p.canonical}" />${robots}\n` + ` <title>locale set ${req.url}</title>\n</head><body>content of ${req.url}</body></html>`); }) .listen(8942, () => console.log('locale set on port 8942'));curl -s http://127.0.0.1:8942/de/ | tr '\n' ' ' | grep -o -i '<link[^>]*>' | tr -s ' '<link rel="alternate" hreflang="en" href="http://127.0.0.1:8942/en/" /> <link rel="alternate" hreflang="de" href="http://127.0.0.1:8942/de/" /> <link rel="alternate" hreflang="fr" href="http://127.0.0.1:8942/fr/" /> <link rel="canonical" href="http://127.0.0.1:8942/de/" />Three valid annotations, absolute and self-including, with a canonical naming the page itself. Every single page rule passes. Nothing says Spanish is missing, because the Spanish URL is not here to be wrong.
- Step 2.
Crawl the set and print the matrix. Save this as
hreflang-matrix.mjs.const seed = process.argv[2]; const attr = (t, n) => { const m = new RegExp(n + '=["\']?([^"\'\\s>]+)', 'i').exec(t); return m && m[1]; }; const pages = new Map(), queue = [seed]; while (queue.length) { const url = new URL(queue.shift()).href; if (pages.has(url)) continue; const res = await fetch(url, { redirect: 'follow' }); const body = await res.text(); const set = []; for (const m of body.matchAll(/<link\b[^>]*>/gis)) { const t = m[0]; if (!/rel=["']?alternate["']?/i.test(t) || !/hreflang=/i.test(t)) continue; set.push({ value: attr(t, 'hreflang'), href: new URL(attr(t, 'href'), res.url).href }); } const can = [...body.matchAll(/<link\b[^>]*>/gis)].find((m) => /rel=["']?canonical["']?/i.test(m[0])); const meta = /<meta\b[^>]*name=["']?robots["']?[^>]*>/i.exec(body); pages.set(url, { status: res.status, set, canonical: can ? new URL(attr(can[0], 'href'), res.url).href : null, robots: [meta && attr(meta[0], 'content'), res.headers.get('x-robots-tag')].filter(Boolean).join(' '), }); for (const e of set) if (!pages.has(e.href)) queue.push(e.href); } const list = [...pages.keys()], idx = (u) => list.indexOf(u) + 1; const names = (a, b) => pages.get(a).set.filter((e) => e.href === b).map((e) => e.value).join('+'); console.log('pages in the set'); for (const u of list) console.log(` ${idx(u)} ${u} ${pages.get(u).status}`); console.log('\nreturn-link matrix, row names column'); console.log(' ' + list.map((_, i) => String(i + 1).padEnd(14)).join('').trimEnd()); for (const a of list) console.log(` ${idx(a)} ` + list.map((b) => (names(a, b) || '.').padEnd(14)).join('').trimEnd()); console.log('\none-way pairs'); let oneWay = 0; for (const a of list) for (const b of list) { if (a === b || !names(a, b) || names(b, a)) continue; oneWay++; console.log(` ${idx(a)} -> ${idx(b)} page ${idx(a)} names page ${idx(b)} as "${names(a, b)}", page ${idx(b)} has no annotation for page ${idx(a)}: DROPPED`); } if (!oneWay) console.log(' none'); console.log('\ncanonical'); for (const u of list) console.log(` ${idx(u)} ${pages.get(u).canonical === u ? 'self' : 'ELSEWHERE ' + pages.get(u).canonical}`); console.log('\nindexable'); for (const u of list) { const p = pages.get(u); const bad = p.status !== 200 || /noindex|none/i.test(p.robots); console.log(` ${idx(u)} ${bad ? 'NOT INDEXABLE' : 'yes'} status ${p.status} robots "${p.robots || '(none)'}"`); }node hreflang-matrix.mjs http://127.0.0.1:8942/en/ | head -12pages in the set 1 http://127.0.0.1:8942/en/ 200 2 http://127.0.0.1:8942/de/ 200 3 http://127.0.0.1:8942/fr/ 200 4 http://127.0.0.1:8942/es/ 200 return-link matrix, row names column 1 2 3 4 1 x-default+en de fr es 2 en de fr . 3 en de fr es 4 en de fr esThe crawl queues every URL an annotation names, so the page list is discovered, not supplied. Read it by column: column 4 has a dot in row 2, and that is the defect. Cell 1,1 holds two values because
x-defaultandenname the same URL. - Step 3.
Read the pairs the matrix makes asymmetric.
node hreflang-matrix.mjs http://127.0.0.1:8942/en/ | sed -n '/one-way/,/^$/p'one-way pairs 4 -> 2 page 4 names page 2 as "de", page 2 has no annotation for page 4: DROPPEDOne pair, not one page. Spanish still pairs with English and French. Only the Spanish to German direction is lost, so the set works for most visitors and fails for one market. Nothing on the Spanish page is malformed.
- Step 4.
Check what each member declares as its canonical.
node hreflang-matrix.mjs http://127.0.0.1:8942/en/ | sed -n '/^canonical/,/^$/p'canonical 1 self 2 self 3 ELSEWHERE http://127.0.0.1:8942/en/ 4 selfPage 3 is the French version and names the English URL as canonical. The two contradict each other: the set says this URL is the French version, the canonical says it is not the one to index. Google documents that the two have to agree, and a member failing that can cost the cluster, not itself alone.
- Step 5.
Check that every member can be indexed at all.
node hreflang-matrix.mjs http://127.0.0.1:8942/en/ | sed -n '/^indexable/,$p'indexable 1 yes status 200 robots "(none)" 2 yes status 200 robots "(none)" 3 yes status 200 robots "(none)" 4 NOT INDEXABLE status 200 robots "noindex,follow"The Spanish page answers 200 with a complete, reciprocal, valid annotation set, and a
noindexthat removes it from the index those annotations point into. The crawler reads the meta element and theX-Robots-Tagheader, since either can carry it. - Step 6.
Compare two members of a real set, saved from
www.cloudflare.comon 2026-09-11, one request each. Save this ashreflang-pair.mjs.import { readFileSync } from 'node:fs'; const attr = (t, n) => { const m = new RegExp(n + '=["\']?([^"\'\\s>]+)', 'i').exec(t); return m && m[1]; }; const load = (url, file) => { const body = readFileSync(file, 'utf8'); const set = []; for (const m of body.matchAll(/<link\b[^>]*>/gis)) if (/rel=["']?alternate["']?/i.test(m[0]) && /hreflang=/i.test(m[0])) set.push({ value: attr(m[0], 'hreflang'), href: new URL(attr(m[0], 'href'), url).href }); return { url: new URL(url).href, set }; }; const [a, b] = [load(process.argv[2], process.argv[3]), load(process.argv[4], process.argv[5])]; const named = (p, u) => p.set.filter((e) => e.href === u).map((e) => e.value).join('+') || null; for (const [p, q] of [[a, b], [b, a]]) { console.log(p.url); console.log(' annotations ', p.set.length); console.log(' names itself', named(p, p.url) || 'NO'); console.log(' names peer ', named(p, q.url) || 'NO'); } console.log('return link ', named(a, b.url) && named(b, a.url) ? 'reciprocal' : 'ONE-WAY, the pair is dropped'); console.log('set sizes ', `${a.set.length} vs ${b.set.length}, ${a.set.length === b.set.length ? 'equal' : 'the two members disagree about the size of the set'}`);node hreflang-pair.mjs https://www.cloudflare.com/ cf.html https://www.cloudflare.com/de-de/ cf-de.htmlhttps://www.cloudflare.com/ annotations 10 names itself x-default names peer de-de https://www.cloudflare.com/de-de/ annotations 25 names itself NO names peer en-us return link reciprocal set sizes 10 vs 25, the two members disagree about the size of the setThe pair is reciprocal and everything else disagrees. One member declares ten annotations, the other twenty-five. The English page is named only by
x-default, the German page calls that same URLen-usand carries no annotation for itself. Either page alone reads as clean. - Step 7.
Check that the URLs answer the client doing the crawl.
curl -s -o /dev/null -w 'HTTP %{http_code}\n' https://www.cloudflare.com/de-de/HTTP 403At 23:54 on 2026-09-11 this URL answered 403 to a default curl user agent and 200 to a browser one fifteen seconds later, the copy step 6 reads. Minutes afterwards it answered 403 to both. Reachability belongs to the moment and the client, not to the URL, so a return link check calls members missing when a bot filter answers. Stop the local set:
netstat -ano | grep 8942names the PID forStop-Process -Id <pid> -Force.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| No one-way pairs, every canonical self, every member indexable | The set is consistent | Pin the page count and the matrix in a release test. |
| A dot in the matrix on one side only | The return link is missing | Add the annotation to the page that omits it. Both directions have to exist. |
| A whole row of dots | That page was reached but annotates nothing | It was linked into the set and never given the head block. |
| canonical ELSEWHERE | A member disowns itself | Fix before anything else. Its hreflang and its canonical give contradictory instructions. |
| NOT INDEXABLE with status 200 | A noindex on a member of the set | Decide which is wrong, the directive or the annotation, with How to check if a page is indexable. |
| A status other than 200 | The annotation names a URL that does not answer | Re-run before filing. Step 7 shows the same URL answering 200 and 403 minutes apart. |
| More pages discovered than the set should contain | A member annotates a URL nobody else names | Usually a stale locale left in one template. |
Common mistakes
What to check next
- How to check hreflang tags: the value rules assumed here.
- How to check canonical tag: the declaration contradicting the set in step 4.
- How to check a self-referencing canonical: why a member's canonical names the member.
- How to check if a page is noindex: the directive that made page 4 useless.
- How to check if a page is indexable: other reasons a URL never gets indexed.
- Meta tag checker: reads the canonical, robots directives and hreflang of a URL.
FAQ
Can I have multiple rel alternate tags for a webpage?
Yes. One <link rel="alternate"> per language version is normal, and step 6 has twenty-five. rel="alternate" also marks feeds and media specific versions, so match on the hreflang attribute, not on rel.
What is an hreflang return link?
The annotation on page B naming page A, when page A names page B. Google requires both directions. Step 3 shows a one-sided pair, ignored while the rest of the set works.
Does one broken pair break the whole set?
No. Step 3 drops one direction and leaves the other pairs intact. A member whose canonical names a different URL is worse, because it contradicts the set instead of omitting a link.
Can hreflang be delivered outside the HTML?
Yes, as a Link response header or in an XML sitemap, under the same rules. A sitemap declares both directions at once, so validating it alone cannot find the defect in step 3.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: meta-tags title, description, canonical, robots meta, hreflang, Open Graph, Twitter card, viewport
- All crawlability and indexing checks
advanced12 minpublished updated Maks Verny