Skip to content

SEO

hreflang in sitemaps vs in HTML: pick one and keep it consistent

hreflang can live in the page head, in an HTTP header or in the XML sitemap. This guide compares the three, shows a working sample of each, and explains how to pick one so the sets never disagree.

getReport teamUpdated 25 Sept 202611 min read

Google accepts hreflang from three places: <link> tags in the HTML head, an HTTP Link header, and xhtml:link entries in the XML sitemap. All three say the same thing, and Google treats them the same. The choice is about who maintains the list, how big the site is, and how you will debug it when a language stops ranking. This guide compares the three, gives a copy-paste sample of each, and explains how to avoid the one mistake that matters: two sources that disagree. Allow an hour to decide and a day to migrate a site from one method to another.

Quick answer

MethodBest forWatch out for
<link rel="alternate" hreflang> in <head>Sites up to a few thousand pages, any CMS with a multilingual pluginEvery version must print the whole set; 20 languages means 20 tags on every page
Link HTTP headerPDFs and other files that have no <head>Set per file at the server; easy to forget on a CDN
xhtml:link in the sitemapLarge sites, headless setups, teams that own the sitemap generatorOne file to regenerate; harder to spot-check by viewing source
  • Pick one method per site. Google reads all three, and a page whose HTML says one thing while the sitemap says another has no reliable set.
  • Every set is complete and reciprocal: each version lists every other version and itself.
  • Codes are ISO 639-1 language plus optional ISO 3166-1 region: en-GB, not en-UK. One x-default per set.
  • Check a page from each template with the hreflang checker; it reads the HTML tags and fetches every alternate.

Why the method matters

hreflang is a matrix. With five languages, every page carries five entries (plus x-default) and every one of those five pages carries the same five. Ten languages is a hundred relationships per page group. Whatever generates that matrix has to know every version of every page, and it has to run again whenever one version is added, removed or moved.

The three methods differ in where that knowledge lives. HTML tags are printed by the page template, so the template needs access to the translation relation at render time; multilingual CMS plugins have it, hand-built sites often do not. The sitemap is built by one generator that already walks every page, so adding the alternates is one loop, and the page templates stay clean. The HTTP header is for the case the other two cannot cover: a file with no HTML at all.

What Google does not do is merge them (what hreflang is and how it is evaluated). If the English page's HTML lists de and fr, and the sitemap lists de, fr and es, the Spanish page has no reciprocal link from the HTML and the set is inconsistent. In practice one of the two sources was written first and the other was added by a plugin later; nobody notices until Spain drops.

How getReport checks it

The checker reads the <link rel="alternate" hreflang> tags in the page's HTML. That is the first method only: sitemap-based sets and Link headers are not read by this tool yet, which is worth knowing before you conclude a sitemap-based site "has no hreflang". If your tags are in the sitemap, open the file with the sitemap validator to confirm it parses, and search the XML for xhtml:link.

For HTML tags, three findings come out of it:

The validation happens on the page you entered; the return-link and reachability findings come from fetching up to 50 of the alternates and reading their own tags. The table under the findings is the matrix, one row per alternate:

The hreflang alternates table: each language code with its URL, the HTTP status it returned, whether it links back to the tested page, and its html lang attribute
Each alternate is fetched and its own hreflang set is read; the Return link column is the contract check.

The "html lang" column is the quick sanity check for a mixed setup: a de alternate whose <html lang> says en usually means the alternate URL points at an untranslated fallback page.

The three methods, with samples

Every version of the page carries the same block, including a line for itself:

HTML
<!-- In <head> of every version of this page, identical on all of them -->
<link rel="alternate" hreflang="en-GB" href="https://example.com/uk/shoes/" />
<link rel="alternate" hreflang="en-US" href="https://example.com/us/shoes/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/schuhe/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/shoes/" />

Absolute URLs with the scheme, and the exact canonical form of each page (trailing slash, www or not, https). A href that differs from the target page's canonical by one character is a different URL to Google, and the return link then fails.

Debugging is the strength here: view source, and the set is in front of you. The weakness is size. The block is repeated on every page, and a large multilingual site adds a few kilobytes of <head> to every response.

A PDF, a downloadable price list or a plain-text file has no head. The server sends the same information as a header:

Text
Link: <https://example.com/uk/brochure.pdf>; rel="alternate"; hreflang="en-GB",
      <https://example.com/de/broschuere.pdf>; rel="alternate"; hreflang="de",
      <https://example.com/brochure.pdf>; rel="alternate"; hreflang="x-default"

On nginx, per file or per location:

nginx
location = /uk/brochure.pdf {
    add_header Link '<https://example.com/uk/brochure.pdf>; rel="alternate"; hreflang="en-GB", <https://example.com/de/broschuere.pdf>; rel="alternate"; hreflang="de", <https://example.com/brochure.pdf>; rel="alternate"; hreflang="x-default"';
}

On Apache, in .htaccess or the virtual host with mod_headers on:

Apache
<Files "brochure.pdf">
    Header set Link '<https://example.com/uk/brochure.pdf>; rel="alternate"; hreflang="en-GB", <https://example.com/de/broschuere.pdf>; rel="alternate"; hreflang="de", <https://example.com/brochure.pdf>; rel="alternate"; hreflang="x-default"'
</Files>

Each file in the set sends the whole set, the same as with HTML. Files served from a CDN or an object store get their headers set there, not on the origin.

One <url> per version, each listing the full set as children. The urlset needs the extra xmlns:xhtml namespace:

XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://example.com/uk/shoes/</loc>
    <xhtml:link rel="alternate" hreflang="en-GB" href="https://example.com/uk/shoes/" />
    <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/schuhe/" />
    <xhtml:link rel="alternate" hreflang="hr" href="https://example.com/hr/cipele/" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/uk/shoes/" />
  </url>
  <url>
    <loc>https://example.com/de/schuhe/</loc>
    <xhtml:link rel="alternate" hreflang="en-GB" href="https://example.com/uk/shoes/" />
    <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/schuhe/" />
    <xhtml:link rel="alternate" hreflang="hr" href="https://example.com/hr/cipele/" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/uk/shoes/" />
  </url>
  <url>
    <loc>https://example.com/hr/cipele/</loc>
    <xhtml:link rel="alternate" hreflang="en-GB" href="https://example.com/uk/shoes/" />
    <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/schuhe/" />
    <xhtml:link rel="alternate" hreflang="hr" href="https://example.com/hr/cipele/" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/uk/shoes/" />
  </url>
</urlset>

Three pages, four entries each, twelve xhtml:link lines. The repetition is the point: every version's <url> block is the complete set, so the reciprocity Google requires is guaranteed by construction. A generator that loops over page groups and prints the same list under each member cannot produce a missing return link.

The sitemap still has to be a valid sitemap: absolute <loc> URLs on the same host, under 50,000 URLs and 50 MB per file, well-formed XML. A multilingual sitemap grows quickly (each language version is its own <url>), so a sitemap index per language is common. The rules are in XML sitemap validation.

Step by step

1. Decide, then write it down

Choose by who owns the data:

  • A multilingual plugin (WPML, Polylang, TranslatePress, Shopify Markets) already prints HTML tags from its translation relation. Use them and do nothing else.
  • A headless or custom build where the sitemap is generated from the routing table: put the set in the sitemap and keep the templates free of it.
  • A site with translated documents: HTML for the pages, Link header for the files. That is two methods, but for disjoint URLs, which is fine.

Write the decision into the project's README or the SEO plugin's notes. The next person who installs an SEO plugin with "add hreflang" switched on needs to know the sitemap already does it.

2. Turn off the second source

Search the rendered HTML of one page for hreflang. If you chose the sitemap method and tags still appear, find the plugin or template printing them and disable that feature. If you chose HTML and the sitemap contains xhtml:link, remove the alternates from the generator. Keep the sitemap itself.

3. Build the set from one list

Whichever method, the set comes from one mapping between versions, never from "the other languages I know about on this page". A minimal build-time helper for a static site:

JavaScript
// build/hreflang.js: one map per page group, used by both the HTML template
// and the sitemap generator, so the two can never disagree.
const groups = [
  { 'en-GB': '/uk/shoes/', de: '/de/schuhe/', hr: '/hr/cipele/', 'x-default': '/uk/shoes/' },
];
const base = 'https://example.com';

export function htmlTags(group) {
  return Object.entries(group)
    .map(([lang, path]) => `<link rel="alternate" hreflang="${lang}" href="${base}${path}" />`)
    .join('\n');
}

export function sitemapLinks(group) {
  return Object.entries(group)
    .map(([lang, path]) => `<xhtml:link rel="alternate" hreflang="${lang}" href="${base}${path}" />`)
    .join('\n');
}

If a page has no translation in a language, that language is simply absent from its group. Never point a missing language at the home page or at the language selector; that is a wrong alternate, and it breaks the return link on the page it points to.

4. Check the codes

en, en-GB, de-AT, pt-BR, zh-Hant-TW are valid. en-UK is not (the ISO 3166-1 code for the United Kingdom is GB), en_GB with an underscore is not, uk alone is the Ukrainian language, and english is a word. The first finding lists each invalid code it finds.

5. Run the checker on one page per template

The home page, one category, one product, one article. Read the table row by row: status 200 for every alternate, "yes" in the return-link column, the expected html lang. A template bug shows up as the same failure on every page of that type. See Hreflang return links for reading each column and fixing what it shows.

Platform notes

WordPress

WPML, Polylang and TranslatePress print HTML tags from their translation relation, self-reference included. Yoast SEO and Rank Math generate sitemaps, not hreflang: their sitemap files do not carry xhtml:link entries, and Yoast's documentation is explicit that hreflang is left to the multilingual plugin. So on WordPress the realistic choice is the HTML method via the multilingual plugin. If you want the sitemap method instead, you need a sitemap generator built for it or a custom one; confirm by opening the sitemap and searching for xhtml:link before assuming it is there.

Two plugins printing tags is the WordPress-specific failure. If the multilingual plugin and an SEO plugin both have an hreflang switch, leave one on.

Shopify

Shopify Markets prints HTML tags for every market and language combination, including the self-reference, and you cannot change the method. Each market's domain or subfolder must be live; a market domain that redirects shows up as a redirect in the table.

Static sites and custom

Hugo, Astro, Next.js and Nuxt each emit HTML alternates from their i18n routing, and each can be given a sitemap plugin that emits xhtml:link from the same routing table. Choose one output and disable the other in the framework's config. For a site that also ships translated PDFs, add the Link header in the host's header configuration (_headers on Netlify, headers in vercel.json).

Verify

  • The hreflang checker shows every alternate with status 200, "yes" for the return link and a matching html lang; the first finding reads "hreflang tags are complete and valid".
  • For the sitemap method: the sitemap validator reports the file as valid, and curl -s https://example.com/sitemap.xml | grep -c xhtml:link returns the number of versions times the number of pages.
  • View source on two versions of the same page: the HTML either has identical sets on both or no set at all (sitemap method). Not one of each.
  • After the next crawl, Search Console's URL inspection shows each version indexed under its own URL rather than folded into another language's canonical.

Common mistakes

  • HTML tags and sitemap entries that disagree. Symptom: some languages rank, some do not, and the tool shows return links missing for the ones the sitemap added later. Fix: one source; delete the other.
  • Canonical pointing at one language. A <link rel="canonical"> from /de/ to /en/ tells Google to drop the German page, and hreflang cannot rescue it. Every version is its own canonical. See Canonical tags explained.
  • noindex on an alternate. A noindex page cannot be shown in any country, so listing it as an alternate is a contradiction; Google drops it from the set. Remove the entry or the noindex.
  • Alternates that redirect. After a slug change, the tag still points at the old URL, which 301s to the new one. Google wants the final URL in the tag. The table shows the status the alternate ended with; a redirected entry is one to fix at the source.
  • Relative URLs in the sitemap. <xhtml:link href="/de/schuhe/"> is invalid; sitemaps have no base URL. Absolute, with scheme, always.
  • Adding the set to only the pages that have translations "so far". The set on every member must be identical; a page added to the German site later must appear on the English page's list the same day.
Check your site before and after Check