Every URL in your hreflang annotations must return 200 OK directly — no 404, no redirect. Google verifies targets on crawl. A broken target invalidates that annotation and degrades the cluster's overall signal. Common causes: URL changes, content deletions, locale launches that didn't backfill, slug renames. The fix is a one-time cleanup plus CI validation so it doesn't recur.
Hreflang target URLs must:
# Extract every unique hreflang URL on the site
URLS=$(curl -s https://example.com/sitemap.xml | \
grep -oE 'hreflang="[^"]+" href="[^"]+"' | \
grep -oE 'href="[^"]+"' | \
awk -F'"' '{print $2}' | sort -u)
# Check status of each
for url in $URLS; do
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url")
if [ "$status" != "200" ]; then
echo "$status $url"
fi
done
// Cluster before
{
'en': 'https://example.com/en/discontinued-product',
'fr': 'https://example.com/fr/produit-arrete', // 404
'de': 'https://example.com/de/eingestellt' // 404
}
// Two options:
// A) Remove the cluster entirely if all variants gone
// B) Remove deleted locales, keep remaining variants
{
'en': 'https://example.com/en/discontinued-product'
// fr and de removed since they no longer exist
}
// Even then, consider whether single-variant clusters need hreflang at all
// Target redirects: 'fr': 'https://example.com/fr/old-slug' → 301 → 'https://example.com/fr/new-slug' // FIX: update the cluster to point at the final URL directly 'fr': 'https://example.com/fr/new-slug'
Retry. If persistent, find why the page errors out. Don't remove from cluster until you're sure it's permanently broken.
// locales-config.js — update the 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'
// No 'es' here because Spanish page was deleted
},
// ...
};
// Redeploy all variants — every page now reflects the cleaned cluster
The cheapest insurance against broken targets is a pre-deploy script that fetches every hreflang URL and fails the build on any non-200.
// scripts/validate-hreflang-targets.js
const fetch = require('node-fetch');
const { clusters } = require('./locales-config');
async function validate() {
const allUrls = new Set();
for (const cluster of Object.values(clusters)) {
for (const [lang, url] of Object.entries(cluster)) {
if (lang !== 'x-default') allUrls.add(url);
}
}
const errors = [];
for (const url of allUrls) {
try {
const r = await fetch(url, { method: 'HEAD', redirect: 'manual' });
if (r.status !== 200) {
errors.push(`${r.status} ${url}`);
}
} catch (e) {
errors.push(`ERR ${url} — ${e.message}`);
}
}
if (errors.length) {
console.error('Hreflang target validation failed:');
errors.forEach(e => console.error(' ' + e));
process.exit(1);
}
console.log(`Validated ${allUrls.size} hreflang targets — all 200`);
}
validate();
name: Validate hreflang targets
on: [push, deployment_status]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: node scripts/validate-hreflang-targets.js
You launch Spanish. Before deploy:
You discontinue German. Before unpublishing:
Doing the steps in this order prevents a window where en/fr/es declare a German alternate that 404s.
French team renames /fr/produit to /fr/produits. Update cluster config, redeploy. Otherwise English/German pages declare the old /fr/produit which now 301s, breaking the cluster.