How to check hreflang tags, including the return links

Short answer

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

Format A two-letter language code (ISO 639-1), optionally followed by a region (ISO 3166-1): 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.
Region alone Not valid. 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.
One URL per code Each language-region code should appear once. Two URLs for 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…ProblemFix
returns 404 or 500A version that doesn't existPoint at the working page, or remove the line from every version
redirectsNot its final addressUse the URL it redirects to
is noindexCan't be shown in searchMake it indexable, or drop it from the set
has a canonical pointing elsewhereGoogle may ignore the annotationUse the URL its canonical names
is a relative URL like /fr/widgetsGoogle requires full URLsWrite 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

  1. Open each URL from the list in a new tab.
  2. View its source (Ctrl+U) and search for hreflang.
  3. 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.
  4. 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.languages in 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 FrogSitebulbWhat it means
Hreflang: Missing Return LinksMissing reciprocal hreflang (no return-tag)Page A points to page B, but B doesn't point back.
Hreflang: Non-200 Hreflang URLsHas outgoing hreflang annotations to broken URLsAn alternate URL doesn't load.
Hreflang: Incorrect Language & Region CodesHas invalid outgoing hreflang annotationsA language or region code isn't valid, such as en-UK.
Hreflang: Multiple EntriesHas conflicting outgoing hreflang annotationsOne language points at more than one URL.
Hreflang: Missing Self ReferenceNo direct matchThe page doesn't list itself in its own set.
No direct matchHas outgoing hreflang annotations to noindex URLsAn alternate URL can't be indexed.
No direct matchMissing HTML lang attributeThe 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-UK and number regions such as es-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.