# 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.

Updated 2026-09-25 · Technical SEO · HTML version: https://getreport.app/guides/hreflang-in-sitemaps-vs-in-html

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

| Method | Best for | Watch out for |
| --- | --- | --- |
| `<link rel="alternate" hreflang>` in `<head>` | Sites up to a few thousand pages, any CMS with a multilingual plugin | Every version must print the whole set; 20 languages means 20 tags on every page |
| `Link` HTTP header | PDFs and other files that have no `<head>` | Set per file at the server; easy to forget on a CDN |
| `xhtml:link` in the sitemap | Large sites, headless setups, teams that own the sitemap generator | One 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](https://getreport.app/tools/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](https://getreport.app/learn/hreflang)). 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

> **Free tool:** [Hreflang checker](https://getreport.app/tools/hreflang-checker): Validate the hreflang tags of a page and fetch every alternate to confirm it links back. Invalid codes, missing self-reference, dead alternates and broken return links, in one table.

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](https://getreport.app/tools/sitemap-validator) to confirm it parses, and search the XML for `xhtml:link`.

For HTML tags, three findings come out of it:

> **Check: hreflang tags are complete and valid.** hreflang tells Google which language or country version to show each searcher. A missing self-reference or an invalid code makes the whole set ignored, so visitors land on the wrong version.
>
> 1. Use valid codes (language, optionally a region; for example "en", "en-GB", "de-AT") and at most one "x-default".
> 2. Include this page's own URL in its hreflang set, and list the same set on every version.

> **Check: Every hreflang alternate links back to this page.** hreflang only works when every version lists every other version, including itself. An alternate that does not point back makes Google ignore the pair, so visitors get the wrong language.
>
> 1. On each alternate page, add a <link rel="alternate" hreflang="…"> for this page and for itself.
> 2. Generate the tags from one list (CMS, plugin or the XML sitemap with xhtml:link) so every page carries the same set.

> **Check: Every hreflang alternate returns HTTP 200.** An alternate that answers 404 or redirects is dropped from the hreflang set, and the whole set can be ignored. Old language versions are often removed without updating the tags.
>
> 1. Point each hreflang entry at a live URL that answers 200 directly (no redirect).
> 2. Remove entries for language versions you no longer publish.

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](https://getreport.app/guides/img/hreflang-in-sitemaps-vs-in-html/alternates.webp "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

### 1. Link tags in the HTML head

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.

### 2. The HTTP Link header, for files

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.

### 3. xhtml:link in the XML sitemap

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](https://getreport.app/guides/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:

```js
// 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](https://getreport.app/guides/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](https://getreport.app/guides/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="https://getreport.app/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.
