Hreflang can be declared in HTML <head> tags OR in sitemap XML. Both methods work. The problem starts when a site declares it in both AND the declarations disagree. Google sees one set of clusters in the HTML and another in the sitemap, and silently picks one or ignores both. The fix is to commit to a single source of truth, generate from that source, and remove the other declaration entirely.
<head> <link rel="canonical" href="https://example.com/en/about" /> <link rel="alternate" hreflang="en" href="https://example.com/en/about" /> <link rel="alternate" hreflang="fr" href="https://example.com/fr/a-propos" /> <link rel="alternate" hreflang="de" href="https://example.com/de/uber-uns" /> <link rel="alternate" hreflang="x-default" href="https://example.com/en/about" /> </head>
<?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/en/about</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/a-propos"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/uber-uns"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/about"/>
</url>
<url>
<loc>https://example.com/fr/a-propos</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/a-propos"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/uber-uns"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/about"/>
</url>
<!-- ... -->
</urlset>
Both are SEO-equivalent. Choose based on operational factors:
curl -s https://example.com/en/about | grep -E "hreflang|alternate" # Lists every hreflang annotation in the HTML head
curl -s https://example.com/sitemap.xml | grep -E "xhtml:link" # If empty, sitemap doesn't declare hreflang — you're using HTML head only # If present, sitemap declares hreflang — check whether HTML head also does
Choose one. Remove the other. The choice doesn't affect SEO — operational simplicity matters more.
Remove xhtml:link entries from sitemap.xml. Sitemap stays valid as plain loc-only entries.
<!-- Sitemap reduced to bare loc entries --> <url> <loc>https://example.com/en/about</loc> <lastmod>2024-01-15</lastmod> </url> <!-- No xhtml:link tags -->
Remove hreflang from HTML head. Keep self-canonical only.
<head> <link rel="canonical" href="https://example.com/en/about" /> <!-- No hreflang here; sitemap handles it --> </head>
Both methods should pull from the same config. Source of truth lives in code or database, not in two places that drift independently.
// locales-config.js (single source of truth)
export const clusters = {
about: {
'en': 'https://example.com/en/about',
'fr': 'https://example.com/fr/a-propos',
'de': 'https://example.com/de/uber-uns',
'x-default': 'https://example.com/en/about'
},
contact: {
'en': 'https://example.com/en/contact',
'fr': 'https://example.com/fr/contact',
// ...
}
};
// If HTML head method:
// Each page template imports clusters[currentPage] and renders <link> tags
// If sitemap method:
// Build script imports all clusters and renders sitemap.xml with xhtml:link entries
// scripts/generate-hreflang-sitemap.js
const { clusters } = require('./locales-config');
const fs = require('fs');
function buildSitemap(clusters) {
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
xml += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n`;
xml += ` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n`;
for (const [pageId, cluster] of Object.entries(clusters)) {
for (const [lang, url] of Object.entries(cluster)) {
if (lang === 'x-default') continue;
xml += ` <url>\n`;
xml += ` <loc>${url}</loc>\n`;
// Each URL declares its full cluster as alternates
for (const [altLang, altUrl] of Object.entries(cluster)) {
xml += ` <xhtml:link rel="alternate" hreflang="${altLang}" href="${altUrl}"/>\n`;
}
xml += ` </url>\n`;
}
}
xml += `</urlset>\n`;
return xml;
}
fs.writeFileSync('./public/sitemap.xml', buildSitemap(clusters));
console.log('Sitemap regenerated');
Site migrated to sitemap method but old SEO plugin still emits HTML head tags. Disable the plugin's hreflang output or uninstall.
HTML head includes en/fr/de/es. Sitemap only includes en/fr/de. Spanish either gets ignored or gets partial signals. Single source of truth prevents this.
HTML head uses /fr/about. Sitemap uses /fr/a-propos/. URL paths differ. Google treats them as different clusters or rejects both. Always source URLs from the same generator.