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

Steps

  1. Step 1.

    Start a four locale set with three planted defects. Save it as hreflang-set.js and run it. The German page omits Spanish, the French canonical points at English, the Spanish page is noindex.

    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.

  2. 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 -12
    
    pages 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            es

    The 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-default and en name the same URL.

  3. 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: DROPPED

    One 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.

  4. 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 self

    Page 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.

  5. 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 noindex that removes it from the index those annotations point into. The crawler reads the meta element and the X-Robots-Tag header, since either can carry it.

  6. Step 6.

    Compare two members of a real set, saved from www.cloudflare.com on 2026-09-11, one request each. Save this as hreflang-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.html
    
    https://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 set

    The 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 URL en-us and carries no annotation for itself. Either page alone reads as clean.

  7. 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 403

    At 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 8942 names the PID for Stop-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

Sign: Every page passes a single page hreflang check and one language version still does not appear for its market.Cause: Reciprocity is a property of a pair, not of a page. The German page in step 1 has three valid, absolute, self-including annotations and passes every rule that can be applied to one document. The defect is the Spanish URL that is not on it, and nothing on the page can report an absence. Only the matrix in step 2 shows it.
Sign: An hreflang cluster stops working after a canonical change nobody connected to it.Cause: A member whose canonical points at another URL is telling an engine to index something else, while the set is telling it that this URL is the version for a language. Google documents that the two have to agree. Step 4 finds it in one line, and the canonical change that caused it usually shipped in a different release.
Sign: A crawler reports set members as unreachable and a browser opens all of them.Cause: Bot management answers the automated request. Step 7 shows one URL answering 403 to curl and 200 to a browser user agent fifteen seconds apart, then 403 to both a few minutes later. Treat a non-200 in a return link report as unknown rather than as a defect, and confirm by hand.
Sign: An audit tool reports the set as valid because it only read the sitemap.Cause: Annotations can arrive in the head, in a Link header or in an XML sitemap, and the three are not required to agree. A sitemap declares both directions in one file, so it can never show a one-way pair. Crawl the pages themselves, as step 2 does, and compare the result with the sitemap.

What to check next

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.

advanced12 minpublished updated Maks Verny