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
- curl 7.x or later. One request saves the body, and later steps read that file.
- Node 22. Its
Intlobject carries the CLDR code lists, so the checker compares against data, not a pattern. - The
hreflangattribute on MDN and Google's page on localized versions of a page, which defines the grammar tested here.
Steps
- Step 1.
Save a real multilingual page, then list its annotations. Measured against
www.cloudflare.comon 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=1317143tr '\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
trmatters: 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. - 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 devalue 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 - okuk,es-laandar-arall 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-UKline is a disagreement inside the tooling. CLDR carriesUKas an alias and resolves it to United Kingdom, while ISO 3166-1 reservesUKand assignsGB.Intl.getCanonicalLocales('en-UK')returnsen-GB, and the checker reports that gap. - 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 -> xdefaultThe parser rejects
x-default, so a checker built onIntlalone calls the one annotation every set should carry invalid. It acceptsxdefault, the same value without the hyphen, because an eight letter string is a legal language subtag.node hreflang-value.mjs x-default X-DEFAULT xdefaultvalue 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 lettersThe checker special cases the value before parsing, and its two letter rule catches the typo.
x-defaultnames the page to serve when nothing matches. - 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.htmlpage 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 hrefsEvery value is in the right code list and every
hrefis absolute. Question the self-include line:x-defaultis the only annotation naming this page, and the set has no entry for English at all. - 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.htmlpage 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 setFour facts, and only the last is a syntax error. This member carries 25 annotations where the English page carried 10.
es-lareads as Spanish in Laos, because LA is Laos and Latin America has no code.ar-aris Arabic in Argentina. The page omits itself. - 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/brokenpage 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 hrefsFour rejected values, one relative
href, and a page naming every locale except itself. - 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 setOne 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/fixedpage 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 hrefsAbsolute values, a self-include, an
x-default. Stop the target:netstat -ano | grep 8941names the PID forStop-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
What to check next
- How to validate hreflang tags: the return link matrix, which needs every URL in the set fetched.
- How to check canonical tag: a set is ignored when a member's canonical disagrees with it.
- How to check a self-referencing canonical: the same relative URL problem, one attribute away.
- How to check the html lang attribute: the declaration a browser reads, not this one.
- Meta tag checker: reads the canonical, robots directives and hreflang of a URL.
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.
Related on this site
- Checker: meta-tags title, description, canonical, robots meta, hreflang, Open Graph, Twitter card, viewport
- All crawlability and indexing checks
intermediate9 minpublished updated Maks Verny