/ Hreflang Fixes / Broken Targets

How to Fix Hreflang Broken Targets

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.

1. The rule

Hreflang target URLs must:

2. Audit current targets

Step 1
Run the Hreflang Checker
Each annotation target gets a status probe. Findings grouped by status code: 404, 301/302, 410, 5xx.
Step 2
Bulk-check yourself
# 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

3. Categorise and fix

404 / 410 — content deleted

// 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

301 / 302 — redirected

// 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'

5xx — server error (transient or persistent?)

Retry. If persistent, find why the page errors out. Don't remove from cluster until you're sure it's permanently broken.

4. Fix at the cluster config level

// 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

5. CI validation

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();

GitHub Actions

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

6. Common scenarios

Scenario: launching a new locale

You launch Spanish. Before deploy:

  1. Translate and publish every page that should have a Spanish variant
  2. Update cluster config to include es
  3. Redeploy — all variants (en, fr, de, es) now declare es as alternate
  4. Verify every es URL returns 200 before announcing the launch

Scenario: deleting a locale

You discontinue German. Before unpublishing:

  1. Update cluster config to remove de entries
  2. Redeploy en, fr, es — their clusters no longer reference German
  3. THEN remove the German content
  4. Set 301 redirects from old German URLs to closest English equivalents (avoids 404 backlinks)

Doing the steps in this order prevents a window where en/fr/es declare a German alternate that 404s.

Scenario: changing slugs in one locale

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.

7. Verify resolution

Step 1
Re-run Hreflang Checker
Broken target findings clear. Every annotation target returns 200.
Step 2
CI passes
The validation script runs green on every pre-deploy.
💡 The most-forgotten step: when launching or removing a locale, update the cluster config BEFORE the content change. Avoids the window where existing variants advertise URLs that don't exist yet, or that no longer exist.

🌍 Re-run the Hreflang Checker

Verify every target returns 200.

Run Hreflang Checker →
Related Guides: Hreflang Fixes  ·  Fix Blocked Targets  ·  Fix Redirect Chains  ·  Hreflang Guide
💬 Got a problem?