How to check hreflang tags

Save the page, flatten the newlines and list the annotations with grep -o -i '<link[^>]*hreflang[^>]*>'. Then check every value: the language is ISO 639-1, the region is ISO 3166-1 alpha-2, each href is absolute, and one annotation names the URL you fetched.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this on staging sign-off for every locale a release adds, and in regression when the head template changes. Most hreflang defects are not missing tags but tags carrying a value from the wrong code list, and the page renders identically either way.

The failure it prevents: a team ships hreflang="uk" for its United Kingdom pages. uk is Ukrainian, the United Kingdom is en-GB, and the annotation is well formed and serves nobody.

Prerequisites

Steps

  1. Step 1.

    Save a real multilingual page, then list its annotations. Measured against www.cloudflare.com on 2026-09-11.

    curl -s -D cf.head -o cf.html -w 'code=%{http_code} bytes=%{size_download}\n' https://www.cloudflare.com/
    
    code=200 bytes=1317143
    tr '\n' ' ' < cf.html | grep -o -i '<link[^>]*hreflang[^>]*>' | tr -s ' '
    
    <link rel="alternate" hreflang="x-default" href="https://www.cloudflare.com/">
    <link rel="alternate" hreflang="es-es" href="https://www.cloudflare.com/es-es/">
    <link rel="alternate" hreflang="fr-fr" href="https://www.cloudflare.com/fr-fr/">
    <link rel="alternate" hreflang="de-de" href="https://www.cloudflare.com/de-de/">
    <link rel="alternate" hreflang="it-it" href="https://www.cloudflare.com/it-it/">
    <link rel="alternate" hreflang="pt-br" href="https://www.cloudflare.com/pt-br/">
    <link rel="alternate" hreflang="zh-cn" href="https://www.cloudflare.com/zh-cn/">
    <link rel="alternate" hreflang="ja-jp" href="https://www.cloudflare.com/ja-jp/">
    <link rel="alternate" hreflang="ko-kr" href="https://www.cloudflare.com/ko-kr/">
    <link rel="alternate" hreflang="zh-tw" href="https://www.cloudflare.com/zh-tw/">

    The tr matters: a template printing each <link> across four lines defeats a single-line pattern, the trap How to check canonical tag shows on a live page.

  2. Step 2.

    Check the values against the code lists, not against a pattern. Save this as hreflang-value.mjs.

    const LANG = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' });
    const REGION = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' });
    export function lint(v) {
      const out = { value: v, lang: '', region: '', verdict: '' };
      if (v.toLowerCase() === 'x-default') return { ...out, lang: '(any)', region: '(any)', verdict: 'ok x-default' };
      if (v.includes('_')) return { ...out, verdict: 'FAIL underscore, the separator is a hyphen' };
      let canon;
      try { canon = Intl.getCanonicalLocales(v)[0]; }
      catch { return { ...out, verdict: 'FAIL not a well formed language tag' }; }
      const p = v.split('-');
      if (p[0].length !== 2) out.verdict = `FAIL language "${p[0]}" is not ISO 639-1, that list is two letters`;
      else if (!LANG.of(p[0])) out.verdict = `FAIL language "${p[0]}" is in no code list`;
      else out.lang = LANG.of(p[0]);
      if (!out.verdict && p.length > 2) out.verdict = `FAIL ${p.length} subtags, the grammar is language or language-region`;
      if (!out.verdict && p[1]) {
        if (!/^[A-Za-z]{2}$/.test(p[1])) out.verdict = `FAIL region "${p[1]}" is not ISO 3166-1 alpha-2`;
        else if (!REGION.of(p[1].toUpperCase())) out.verdict = `FAIL region "${p[1]}" is in no code list`;
        else out.region = REGION.of(p[1].toUpperCase());
      }
      if (!out.verdict && canon.toLowerCase() !== v.toLowerCase())
        out.verdict = `FAIL superseded, the current spelling is "${canon}"`;
      if (!out.verdict) out.verdict = 'ok';
      return out;
    }
    export function row(r) {
      return [r.value.padEnd(12), (r.lang || '-').padEnd(12), (r.region || '-').padEnd(16), r.verdict].join(' ');
    }
    if (process.argv[1].endsWith('hreflang-value.mjs')) {
      console.log(['value'.padEnd(12), 'language'.padEnd(12), 'region'.padEnd(16), 'verdict'].join(' '));
      for (const v of process.argv.slice(2)) console.log(row(lint(v)));
    }
    
    node hreflang-value.mjs x-default en-GB en_GB en-UK eng-US en-XX uk es-la ar-ar zh-hans-cn iw-IL de
    
    value        language     region           verdict
    x-default    (any)        (any)            ok x-default
    en-GB        English      United Kingdom   ok
    en_GB        -            -                FAIL underscore, the separator is a hyphen
    en-UK        English      United Kingdom   FAIL superseded, the current spelling is "en-GB"
    eng-US       -            -                FAIL language "eng" is not ISO 639-1, that list is two letters
    en-XX        English      -                FAIL region "XX" is in no code list
    uk           Ukrainian    -                ok
    es-la        Spanish      Laos             ok
    ar-ar        Arabic       Argentina        ok
    zh-hans-cn   Chinese      -                FAIL 3 subtags, the grammar is language or language-region
    iw-IL        Hebrew       Israel           FAIL superseded, the current spelling is "he-IL"
    de           German       -                ok

    uk, es-la and ar-ar all pass, and the name columns say why they are wrong anyway: Ukrainian, Spanish in Laos, Arabic in Argentina. No syntax rule can reject them, so the names are the part a human reads.

    The en-UK line is a disagreement inside the tooling. CLDR carries UK as an alias and resolves it to United Kingdom, while ISO 3166-1 reserves UK and assigns GB. Intl.getCanonicalLocales('en-UK') returns en-GB, and the checker reports that gap.

  3. Step 3.

    Confirm the one value that a language tag parser has to be told about.

    node -e "for (const v of ['en-GB','x-default','xdefault']) { try { console.log(v.padEnd(10), '->', Intl.getCanonicalLocales(v)[0]); } catch (e) { console.log(v.padEnd(10), '->', e.name + ': ' + e.message); } }"
    
    en-GB      -> en-GB
    x-default  -> RangeError: Incorrect locale information provided
    xdefault   -> xdefault

    The parser rejects x-default, so a checker built on Intl alone calls the one annotation every set should carry invalid. It accepts xdefault, the same value without the hyphen, because an eight letter string is a legal language subtag.

    node hreflang-value.mjs x-default X-DEFAULT xdefault
    
    value        language     region           verdict
    x-default    (any)        (any)            ok x-default
    X-DEFAULT    (any)        (any)            ok x-default
    xdefault     -            -                FAIL language "xdefault" is not ISO 639-1, that list is two letters

    The checker special cases the value before parsing, and its two letter rule catches the typo. x-default names the page to serve when nothing matches.

  4. Step 4.

    Read a whole set at once. Save this as hreflang-read.mjs. Two arguments make it read a saved body against the URL you name, costing no request.

    import { readFileSync } from 'node:fs';
    import { lint } from './hreflang-value.mjs';
    const [url, file] = process.argv.slice(2);
    let body, header = null, base = url;
    if (file) body = readFileSync(file, 'utf8');
    else {
      const res = await fetch(url, { redirect: 'follow' });
      body = await res.text(); header = res.headers.get('link'); base = res.url;
    }
    const set = [];
    for (const m of body.matchAll(/<link\b[^>]*>/gis)) {
      const t = m[0];
      if (!/rel=["']?alternate["']?/i.test(t)) continue;
      const h = /hreflang=["']?([^"'\s>]+)/i.exec(t), u = /href=["']?([^"'\s>]+)/i.exec(t);
      if (h && u) set.push({ value: h[1], href: u[1] });
    }
    if (header)
      for (const m of header.matchAll(/<([^>]+)>\s*;([^,]*)/g))
        if (/rel=["']?alternate["']?/i.test(m[2]) && /hreflang=/i.test(m[2]))
          set.push({ value: /hreflang=["']?([^"';\s]+)/i.exec(m[2])[1], href: m[1] });
    console.log('page      ', base);
    console.log('annotations', set.length);
    let selfRef = null, bad = 0, rel = 0;
    for (const e of set) {
      const r = lint(e.value);
      const absolute = /^https?:\/\//i.test(e.href);
      const resolved = new URL(e.href, base).href;
      if (!absolute) rel++;
      if (r.verdict.startsWith('FAIL')) bad++;
      if (resolved === new URL(base).href) selfRef = e.value;
      console.log(e.value.padEnd(12) + (absolute ? 'abs ' : 'REL ') + resolved.padEnd(41) +
        (r.lang ? `${r.lang}/${r.region || '-'} ` : '') + r.verdict);
    }
    console.log('self-include', selfRef ? `yes as "${selfRef}"` : 'NO, the page is in no annotation of its own set');
    console.log('summary   ', `${set.length} annotations, ${bad} invalid values, ${rel} relative hrefs`);
    
    node hreflang-read.mjs https://www.cloudflare.com/ cf.html
    
    page       https://www.cloudflare.com/
    annotations 10
    x-default   abs https://www.cloudflare.com/              (any)/(any) ok x-default
    es-es       abs https://www.cloudflare.com/es-es/        Spanish/Spain ok
    fr-fr       abs https://www.cloudflare.com/fr-fr/        French/France ok
    …
    zh-tw       abs https://www.cloudflare.com/zh-tw/        Chinese/Taiwan ok
    self-include yes as "x-default"
    summary    10 annotations, 0 invalid values, 0 relative hrefs

    Every value is in the right code list and every href is absolute. Question the self-include line: x-default is the only annotation naming this page, and the set has no entry for English at all.

  5. Step 5.

    Run the same script on another member of the same set, saved at the same time.

    node hreflang-read.mjs https://www.cloudflare.com/de-de/ cf-de.html
    
    page       https://www.cloudflare.com/de-de/
    annotations 25
    en-us       abs https://www.cloudflare.com/              English/United States ok
    …
    es-la       abs https://www.cloudflare.com/es-la/        Spanish/Laos ok
    …
    zh-cn       abs https://www.cloudflare.com/zh-cn/        Chinese/China ok
    ar-ar       abs https://www.cloudflare.com/ar-ar/        Arabic/Argentina ok
    he-il       abs https://www.cloudflare.com/he-il/        Hebrew/Israel ok
    zh-hans-cn  abs https://www.cloudflare.com/zh-hans-cn/   Chinese/- FAIL 3 subtags, the grammar is language or language-region
    self-include NO, the page is in no annotation of its own set

    Four facts, and only the last is a syntax error. This member carries 25 annotations where the English page carried 10. es-la reads as Spanish in Laos, because LA is Laos and Latin America has no code. ar-ar is Arabic in Argentina. The page omits itself.

  6. Step 6.

    Reproduce the structural defects on a target you control. Save it as hreflang-target.js, run it, then read the broken route.

    const http = require('node:http');
    const B = 'http://127.0.0.1:8941';
    const sets = {
      '/broken': [['x-default', `${B}/`], ['en_GB', `${B}/en-gb/`], ['en-UK', `${B}/en-uk/`],
        ['eng-US', `${B}/eng-us/`], ['en-XX', `${B}/en-xx/`], ['de-DE', '/de/']],
      '/fixed': [['x-default', `${B}/fixed`], ['en-US', `${B}/fixed`], ['en-GB', `${B}/en-gb/`], ['de-DE', `${B}/de/`]],
    };
    http
      .createServer((req, res) => {
        const links = (sets[req.url] || [])
          .map(([v, h]) => `  <link rel="alternate" hreflang="${v}" href="${h}" />`).join('\n');
        res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
        res.end(`<!doctype html><html><head>\n${links}\n  <title>hreflang target</title>\n</head><body>one page</body></html>`);
      })
      .listen(8941, () => console.log('hreflang target on port 8941'));
    
    node hreflang-read.mjs http://127.0.0.1:8941/broken
    
    page       http://127.0.0.1:8941/broken
    annotations 6
    x-default   abs http://127.0.0.1:8941/                   (any)/(any) ok x-default
    en_GB       abs http://127.0.0.1:8941/en-gb/             FAIL underscore, the separator is a hyphen
    en-UK       abs http://127.0.0.1:8941/en-uk/             English/United Kingdom FAIL superseded, the current spelling is "en-GB"
    eng-US      abs http://127.0.0.1:8941/eng-us/            FAIL language "eng" is not ISO 639-1, that list is two letters
    en-XX       abs http://127.0.0.1:8941/en-xx/             English/- FAIL region "XX" is in no code list
    de-DE       REL http://127.0.0.1:8941/de/                German/Germany ok
    self-include NO, the page is in no annotation of its own set
    summary    6 annotations, 4 invalid values, 1 relative hrefs

    Four rejected values, one relative href, and a page naming every locale except itself.

  7. Step 7.

    Request the same relative annotation through a second hostname.

    node hreflang-read.mjs http://localhost:8941/broken | grep -E 'page|de-DE'
    
    page       http://localhost:8941/broken
    de-DE       REL http://localhost:8941/de/                German/Germany ok
    self-include NO, the page is in no annotation of its own set

    One byte-identical tag, two annotated URLs, because /de/ resolves against the document carrying it. How to check a self-referencing canonical finds this in a canonical. Here it is worse: a crawlable staging host annotates a whole set of staging URLs.

    node hreflang-read.mjs http://127.0.0.1:8941/fixed
    
    page       http://127.0.0.1:8941/fixed
    annotations 4
    x-default   abs http://127.0.0.1:8941/fixed              (any)/(any) ok x-default
    en-US       abs http://127.0.0.1:8941/fixed              English/United States ok
    en-GB       abs http://127.0.0.1:8941/en-gb/             English/United Kingdom ok
    de-DE       abs http://127.0.0.1:8941/de/                German/Germany ok
    self-include yes as "en-US"
    summary    4 annotations, 0 invalid values, 0 relative hrefs

    Absolute values, a self-include, an x-default. Stop the target: netstat -ano | grep 8941 names the PID for Stop-Process -Id <pid> -Force.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Every value ok, every href abs, self-include yes | The values are well formed | Move to the pairing rules in How to validate hreflang tags. | | FAIL underscore | en_GB instead of en-GB | Replace the separator. A locale identifier in code is not the attribute value. | | FAIL superseded | A deprecated or reserved code such as UK or iw | Use the spelling the checker prints, GB and he. | | Language name reads wrong, verdict ok | A valid code for a language nobody meant | Compare the name column with the locale list the site publishes. uk is Ukrainian. | | Region name reads wrong, verdict ok | A valid country code used as a market label | es-la is Laos, not Latin America. ISO 3166-1 has no code for a continent. | | REL in the href column | A relative annotation | Make it absolute. Step 7 shows the value changing with the hostname. | | self-include NO | The page is not in its own set | Add the annotation for this page's own locale. | | No x-default anywhere | No declared fallback | Add one on the page that should serve unmatched visitors. |

Common mistakes

Sign: A value checker rejects x-default, the one annotation the documentation asks for.Cause: x-default is not a valid Unicode locale identifier, so Intl.getCanonicalLocales throws RangeError on it, as step 3 shows. Any checker built on a language tag parser has to special case the value before parsing, and the typo xdefault has to be caught by a separate rule because an eight letter string parses as a language.
Sign: Every value passes a validator and the annotations still point the wrong audience at the wrong page.Cause: The common defects are legal codes with the wrong meaning. uk is Ukrainian, es-la is Spanish in Laos, ar-ar is Arabic in Argentina. Step 2 and step 5 show all three passing. Print the language and region names next to the value and read them, because the verdict column cannot tell you what somebody meant.
Sign: A checker flags en-UK as unknown on one machine and accepts it on another.Cause: CLDR carries UK as an alias for GB, so Intl.DisplayNames resolves it to United Kingdom, while ISO 3166-1 reserves UK and assigns GB. Compare the value with Intl.getCanonicalLocales of itself instead: a value that canonicalises to a different string is superseded, whatever the display name says.
Sign: grep finds no hreflang on a page whose source clearly has it.Cause: The link elements are pretty printed across several lines and grep matches one line at a time. Flatten the body first, as step 1 does. The same trap hides a canonical on the MDN page linked below.

What to check next

FAQ

What are hreflang tags?

Annotations saying which URL serves which language or country version of one page. They arrive as <link rel="alternate"> elements, as a Link header, or in an XML sitemap. They are a hint about audience, not a redirect.

Is hreflang a language code or a country code?

The first subtag is a language in ISO 639-1. The second, optional, is a country in ISO 3166-1 alpha-2. The order never reverses, and a country code alone is invalid.

What does x-default do?

It names the page shown when no annotated locale matches the visitor, usually a language selector or a global English page. Step 3 shows why a checker needs a rule for it.

Do all search engines use hreflang?

No. Google documents and uses it. Others differ in whether they read it at all, and Google's rules do not describe their behaviour. Read the documentation of the engine whose results matter to you.

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.

intermediate9 minpublished updated Maks Verny