How to check hreflang tags, including the return links
Hreflang tells Google which language or regional version of a page to show each searcher. It only works when every version lists itself and all the others, with full URLs and valid codes such as en-GB. If two pages don't both point to each other, Google ignores the tags. To check, list the page's hreflang tags, then open each version and make sure it links back. The snippet below does both for versions on the same domain.
What hreflang does
If you have the same page in English for the UK, English for the US and French, hreflang tells Google they're versions of each other, so a searcher in Paris gets the French page and one in Leeds gets the UK one. It goes in the head of each page:
<link rel="alternate" hreflang="en-GB" href="https://example.com/uk/widgets">
<link rel="alternate" hreflang="en-US" href="https://example.com/us/widgets">
<link rel="alternate" hreflang="fr-FR" href="https://example.com/fr/widgets">
<link rel="alternate" hreflang="x-default" href="https://example.com/widgets">
x-default is the fallback: the page to show people whose language doesn't match any version. The same information can go in an HTTP Link header (useful for PDFs) or in your XML sitemap instead. Google treats the three methods the same, and using more than one adds nothing. Pick one: mixing them makes mistakes harder to see.
Hreflang doesn't tell Google what language a page is in. Google works that out from the content. Hreflang helps it choose which of your versions to show.
Every version lists every version
The full set of tags has to appear on every version, including a line for the page itself. Here's a three-language set with one link missing:
The UK page points at the German page, but the German page doesn't point back. Google ignores the UK–German pairing, so it may show the wrong one of the two.
This is a common hreflang problem, and the hardest to see, because each page looks fine on its own. It usually happens when one version is added, moved or retired and the others aren't updated.
Language and region codes
en, en-GB, fr-CA. Capitals don't matter. A script such as zh-Hant is allowed too. Codes outside these lists, such as es-419 for Latin America, aren't supported.GB or US on their own aren't languages.en-UK A common mistake. The United Kingdom's code is GB. Google says reserved codes such as UK and EU have no effect.en-GB send mixed signals.Separately, the lang attribute on the <html> tag (<html lang="en-GB">) tells browsers and screen readers which language the page is in, so words are pronounced and translated properly. Google doesn't use it (or hreflang) to work out what language a page is in, but every page should still have one.
Where each tag must point
Each hreflang URL should be the version's final, indexable address. Otherwise the annotation may be ignored:
| The alternate… | Problem | Fix |
|---|---|---|
| returns 404 or 500 | A version that doesn't exist | Point at the working page, or remove the line from every version |
| redirects | Not its final address | Use the URL it redirects to |
| is noindex | Can't be shown in search | Make it indexable, or drop it from the set |
| has a canonical pointing elsewhere | Google may ignore the annotation | Use the URL its canonical names |
is a relative URL like /fr/widgets | Google requires full URLs | Write it out: https://example.com/fr/widgets |
How to check by hand
1Run the hreflang snippet 1 minute
Open DevTools (F12), go to the Console and paste:
// Lists this page's hreflang tags, flags the common mistakes,
// and checks return links for versions on the same domain
const clean = u => u.split('#')[0];
const loose = u => { try { const x = new URL(u); return (x.origin + x.pathname).replace(/\/$/, '').toLowerCase(); } catch (e) { return u; } };
const here = clean(location.href);
const canonical = document.querySelector('link[rel~="canonical" i]')?.href;
const self = new Set([here, canonical && clean(canonical)].filter(Boolean));
const ALT = 'link[rel~="alternate" i][hreflang]:not([rel~="stylesheet" i])';
const tags = [...document.querySelectorAll(ALT)]
.map(l => ({ lang: l.getAttribute('hreflang').trim(), raw: (l.getAttribute('href') || '').trim(), href: clean(l.href) }));
const headerAlternates = h => (h || '').split(/,(?=\s*<)/)
.map(p => p.match(/^\s*<([^>]*)>(.*)$/))
.filter(m => m && /rel\s*=\s*"?[^";]*\balternate\b/i.test(m[2]) && /hreflang\s*=/i.test(m[2]))
.map(m => m[1]);
const seen = new Set();
const rows = [];
for (const t of tags) {
const problems = [];
const lang = t.lang.toLowerCase();
if (lang !== 'x-default' && !/^[a-z]{2}(-[a-z]{4})?(-[a-z]{2})?$/i.test(t.lang)) {
problems.push(/-\d{3}$/.test(t.lang) ? 'number regions such as 419 are not supported' : 'invalid language code');
}
if (/-(uk|eu|un)$/i.test(t.lang)) problems.push(/-uk$/i.test(t.lang) ? 'UK is not a region code: use GB' : 'reserved region code: Google ignores it');
if (!/^https?:\/\//i.test(t.raw)) problems.push(t.raw ? 'not a full URL' : 'no URL');
if (seen.has(lang)) problems.push('language listed twice');
seen.add(lang);
let back = 'other domain: check by hand';
let status = '';
if (!t.raw) back = '';
else if (self.has(t.href)) back = '(this page)';
else if (t.href.startsWith(location.origin + '/')) {
try {
const res = await fetch(t.href, { cache: 'no-store', headers: { Accept: 'text/html' } });
status = res.status + (res.redirected ? ' → ' + res.url : '');
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const listed = [...doc.querySelectorAll(ALT)].map(l => l.getAttribute('href') || '')
.concat(headerAlternates(res.headers.get('link')))
.map(h => { try { return clean(new URL(h, res.url).href); } catch (e) { return ''; } });
const near = listed.find(u => [...self].some(s => loose(s) === loose(u)));
back = !res.ok ? 'page returns ' + res.status
: listed.some(u => self.has(u)) ? 'yes'
: near ? 'NO: lists ' + near + ' (not an exact match)' : 'NO';
} catch (e) { back = 'could not load'; }
}
rows.push({ hreflang: t.lang, url: t.href || t.raw, status, 'links back': back, problems: problems.join('; ') || 'none' });
}
if (tags.length && !tags.some(t => t.lang.toLowerCase() !== 'x-default' && self.has(t.href))) {
rows.push({ hreflang: '(missing)', url: canonical || here, status: '', 'links back': '', problems: 'this page is not in its own list' });
}
console.table(rows.length ? rows : [{ result: 'No hreflang tags in the HTML. Check the HTTP header or the sitemap.' }]);
It lists each hreflang tag with any problems (invalid code, en-UK, unsupported codes such as es-419, relative URL, duplicate language), checks this page is in its own list, and opens each version on the same domain to show its status and whether it links back.
Versions on other domains (such as example.fr) can't be fetched from the Console, so check those by hand. "Links back" needs an exact URL match. If the other page lists a near match, such as a trailing slash or a different query string, the snippet shows it. Run it on the clean URL, without tracking parameters.
2Check the other versions yourself 5 minutes
- Open each URL from the list in a new tab.
- View its source (
Ctrl+U) and search forhreflang. - Check the list is the same set as on the first page, including a line for this version and a line back to the page you started on.
- Check the page loads directly, with no redirect, and doesn't say
noindex.
3If the page shows no tags 2 minutes
The hreflang may be in the HTTP header or the sitemap instead. In DevTools, open Network, reload, click the page's own request and look for a link response header with hreflang in it. If it's not there either, search your XML sitemap for xhtml:link.
On Shopify, WordPress and Next.js
- Next.js: Set
alternates.languagesin the metadata, with a language code for each URL.
How crawlers report this
If a crawl sent you here, these are the labels to look for. Our guide to Screaming Frog and Sitebulb issues covers other common ones.
| Screaming Frog | Sitebulb | What it means |
|---|---|---|
| Hreflang: Missing Return Links | Missing reciprocal hreflang (no return-tag) | Page A points to page B, but B doesn't point back. |
| Hreflang: Non-200 Hreflang URLs | Has outgoing hreflang annotations to broken URLs | An alternate URL doesn't load. |
| Hreflang: Incorrect Language & Region Codes | Has invalid outgoing hreflang annotations | A language or region code isn't valid, such as en-UK. |
| Hreflang: Multiple Entries | Has conflicting outgoing hreflang annotations | One language points at more than one URL. |
| Hreflang: Missing Self Reference | No direct match | The page doesn't list itself in its own set. |
| No direct match | Has outgoing hreflang annotations to noindex URLs | An alternate URL can't be indexed. |
| No direct match | Missing HTML lang attribute | The page doesn't declare its language in the html tag. |
Doing this on every page?
SEOSnip checks hreflang for you
Open the extension on any page and it reads the page's hreflang tags and its lang attribute. Press the link check button and it opens each alternate, including ones on other domains. You get:
- Invalid language codes (including
en-UKand number regions such ases-419), empty or invalid URLs, duplicate languages and a missing self-reference - Alternates that return errors, redirect, are noindex or name a different canonical
- Alternates that don't link back, read from their head or their HTTP header
- A ticket listing each version that fails (the first 10, with the total), and a "Check if fixed" button that re-runs the check
It reads the hreflang tags in the page's HTML. If your hreflang is only in the HTTP header or the sitemap, check it there by hand. The return-link check does read each alternate's header.
Hreflang return links are missing
| Target | Result | Found at |
|---|---|---|
hreflang de-DEhttps://example.com/de/widgets | 200 no link back to this page | head > link:nth-of-type(4) |