Hreflang & International SEO
Validate existing hreflang implementations or generate correct hreflang for multi-language / multi-region sites. Covers HTML metadata, HTTP headers, and XML sitemap implementations, plus content-parity and locale-format auditing.
Our default stack is Next.js 15 (App Router) + next-intl. Prefer generating
hreflang from next/metadata alternates.languages so it stays in sync with
routing; fall back to a sitemap for large or cross-domain sites.
Validation Checks
1. Self-Referencing Tags
- Every page must include an hreflang tag pointing to itself.
- The self-referencing URL must exactly match the page's canonical URL.
- Missing self-referencing tags cause Google to ignore the entire hreflang set.
2. Return Tags (most common failure)
- If page A links to B with hreflang, B must link back to A. Every relationship must be bidirectional (A→B and B→A); the cluster must be a full mesh.
- Missing return tags invalidate the hreflang signal for both pages — Google silently drops the whole annotation set, so symptoms are invisible on-page.
- Cross-domain return tags must point at the exact alternate URL, including protocol and trailing slash.
3. x-default Tag
- Designates the fallback page for unmatched languages/regions.
- Typically the language selector or the primary/English version.
- Exactly one x-default per cluster; it also needs return tags from every language version.
4. Language Code Validation
- Use ISO 639-1 two-letter codes (
en,fr,de,ja). - Common errors:
enginstead ofen(ISO 639-2 is not valid for hreflang)jpinstead ofja(wrong code for Japanese)zhwithout script qualifier (ambiguous — usezh-Hans/zh-Hant)
5. Region Code Validation
- Optional region uses ISO 3166-1 Alpha-2 (
en-US,en-GB,pt-BR). - Format:
language-REGION(lowercase language, uppercase region). Google is case-insensitive but consistent casing prevents diffing bugs. - Common errors:
en-ukinstead ofen-GB(UK is not a valid ISO 3166-1 code)es-LA(Latin America is not a country — use specific countries)- Region without a language prefix (region alone is invalid)
6. Canonical URL Alignment
- Hreflang must only appear on canonical URLs.
- If a page's
rel=canonicalpoints elsewhere, hreflang on it is ignored. - Canonical and hreflang URLs must match exactly (including trailing slash).
- Non-canonical pages must not appear in any hreflang set.
7. Protocol Consistency
- All URLs in a cluster must use the same protocol. Mixed HTTP/HTTPS fails validation. After an HTTPS migration, update every hreflang URL to HTTPS.
8. Cross-Domain Support
- Hreflang works across domains (
example.com↔example.de). - Cross-domain requires return tags on both domains and both domains verified in Search Console. Prefer sitemap-based hreflang for cross-domain setups.
Common Mistakes
| Issue | Severity | Fix |
|---|---|---|
| Missing self-referencing tag | Critical | Add hreflang pointing to the same page URL |
| Missing return tags (A→B but no B→A) | Critical | Add matching return tags on all alternates |
| Missing x-default | High | Add x-default pointing to fallback/selector page |
Invalid language code (e.g., eng) |
High | Use ISO 639-1 two-letter codes |
Invalid region code (e.g., en-uk) |
High | Use ISO 3166-1 Alpha-2 codes |
| Hreflang on non-canonical URL | High | Move hreflang to canonical URL only |
| HTTP/HTTPS mismatch in URLs | Medium | Standardize all URLs to HTTPS |
| Trailing slash inconsistency | Medium | Match canonical URL format exactly |
| Hreflang in both HTML and sitemap | Low | Choose one method (sitemap preferred at scale) |
| Language without region when needed | Low | Add region qualifier for geo-targeted content |
Implementation in Next.js + next-intl
Method 1 — next/metadata alternates (preferred for App Router)
Generate hreflang from the same locale list that drives routing, so a new locale can never silently miss its tags.
// src/i18n/config.ts
export const locales = ["en", "de", "fr", "ja"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
// hreflang value per routing locale (region/script-qualified where needed)
export const hreflangByLocale: Record<Locale, string> = {
en: "en",
de: "de-DE",
fr: "fr-FR",
ja: "ja",
};
// src/lib/seo/alternates.ts
import { locales, defaultLocale, hreflangByLocale } from "@/i18n/config";
const SITE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://example.com
/** Build alternates.languages for a route path that exists in every locale. */
export function buildLanguageAlternates(pathWithoutLocale: string) {
const clean = pathWithoutLocale.replace(/^\/+/, "");
const languages: Record<string, string> = {};
for (const locale of locales) {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
languages[hreflangByLocale[locale]] = `${SITE}${prefix}/${clean}`.replace(/\/+$/, "") || SITE;
}
// x-default → the default-locale URL (or a /select language switcher)
languages["x-default"] = `${SITE}/${clean}`.replace(/\/+$/, "") || SITE;
return languages;
}
// src/app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { buildLanguageAlternates } from "@/lib/seo/alternates";
import { defaultLocale, hreflangByLocale, type Locale } from "@/i18n/config";
export async function generateMetadata(
{ params }: { params: Promise<{ locale: Locale; slug: string }> },
): Promise<Metadata> {
const { locale, slug } = await params;
const path = `blog/${slug}`;
const self =
`${process.env.NEXT_PUBLIC_SITE_URL}${locale === defaultLocale ? "" : `/${locale}`}/${path}`;
return {
// canonical MUST equal the self-referencing hreflang URL
alternates: {
canonical: self,
languages: buildLanguageAlternates(path),
},
};
}
Notes:
alternates.languagesemits<link rel="alternate" hreflang="…">for every entry, including the self-referencing one andx-default— that satisfies the self-ref + return-tag requirements automatically, as long as every locale variant of the page renders the samebuildLanguageAlternates(path).- Keep
canonicalidentical to that locale's entry inlanguages. A mismatch (Check 6) silently voids the whole cluster. - If a page does NOT exist in a given locale, omit it from
languagesfor ALL variants — never link to a 404 or to a redirect target.
Method 2 — HTTP headers (non-HTML files: PDFs, feeds)
Set via middleware, server config, or CDN rules:
Link: <https://example.com/doc.pdf>; rel="alternate"; hreflang="en-US",
<https://example.com/fr/doc.pdf>; rel="alternate"; hreflang="fr",
<https://example.com/doc.pdf>; rel="alternate"; hreflang="x-default"
Method 3 — XML sitemap (large or cross-domain sites)
Centralized, scalable, and the recommended method for cross-domain hreflang.
// src/app/sitemap.ts (Next.js MetadataRoute.Sitemap supports alternates)
import type { MetadataRoute } from "next";
import { buildLanguageAlternates } from "@/lib/seo/alternates";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const paths = ["", "blog/intro", "pricing"]; // from CMS/content
return paths.map((p) => {
const langs = buildLanguageAlternates(p);
return {
url: langs["x-default"],
lastModified: new Date(),
alternates: { languages: langs }, // emits <xhtml:link> per locale
};
});
}
Raw sitemap shape Next.js produces (one full <url> block per alternate, mesh):
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/page</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/page" />
<xhtml:link rel="alternate" hreflang="de-DE" href="https://example.com/de/page" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/page" />
</url>
<!-- ...one matching <url> block per alternate (full mesh) -->
</urlset>
Rules: declare xmlns:xhtml; every <url> includes ALL alternates (itself
included); each alternate gets its own <url> block; split at 50,000 URLs/file.
Method Comparison
| Method | Best for | Pros | Cons |
|---|---|---|---|
metadata alternates |
App Router sites | Stays in sync with routing, type-safe | Bloats <head> on huge clusters |
| HTTP headers | Non-HTML files | Works for PDFs/feeds | Complex config, not visible in HTML |
| XML sitemap | Large / cross-domain | Scalable, centralized | Not on-page, needs sitemap upkeep |
Audit Methodology & Scoring (0–100)
Use this when asked to audit an existing site (live URL or content directory). Crawl every locale variant of each canonical path (use your own crawler/tooling), build the cluster graph, then score.
Process
- Detect locales — URL path, subdomain, ccTLD, and
<html lang>. - Map equivalents — group corresponding pages into clusters.
- Build the graph — for each page record the alternates it declares.
- Validate — run Checks 1–8 above against every cluster.
- Score parity & formats — see rubrics below.
- Emit fixes — corrected
alternates.languages/ sitemap entries, ready to paste.
Hreflang Health Score (per cluster, 0–100)
| Dimension | Points | Pass condition |
|---|---|---|
| Self-referencing on every variant | 25 | All variants self-reference correctly |
| Full-mesh return tags | 25 | Every A↔B pair reciprocates |
| x-default present & valid | 15 | Exactly one, with return tags |
| Valid language/region codes | 15 | All codes ISO 639-1 / 3166-1 |
| Canonical alignment | 10 | hreflang URL == canonical, on canonical pages only |
| Protocol/slash consistency | 10 | Uniform protocol and trailing-slash policy |
Interpretation: 90–100 solid · 70–89 minor gaps · 50–69 signal-degrading · <50 likely fully ignored by Google.
Falsifiability check (always include in an audit)
- How would we know hreflang failed? Search Console → "International Targeting" / page indexing shows "no return tags" errors, or the wrong-locale URL ranks/serves in a target market's SERP. Hreflang failure is otherwise invisible on-page — passing a crawler check is necessary, not sufficient.
- Leading indicator: rising impressions on a locale URL in the wrong country (GSC query → country breakdown) means the cluster is being ignored; re-audit return tags first.
Content Parity Audit
Technical hreflang correctness does not guarantee that each locale provides
equivalent value. After validating tags, audit parity across versions.
Load references/content-parity.md for the full matrix and methodology.
Checks: page exists in every declared locale; section structure equivalence (H2/H3 ±1); FAQ count (±2); localized images/alt; JSON-LD present and localized; title/meta localized (not English); word-count ratio within expansion norms; translation freshness (stale if source updated >30d before the translation).
Parity Score (0–100)
| Dimension | Points |
|---|---|
| Page-existence parity across locales | 30 |
| SEO-element parity (title, meta, schema) | 30 |
| Content-structure parity (sections, images, FAQ) | 25 |
| Freshness parity | 15 |
Word-count ratios vs English: DE 1.25–1.35×, FR/ES 1.15–1.25×, JA 0.75–0.90×, ZH 0.70–0.80×. A DE page shorter than EN usually has missing content; a JA page longer than EN usually has padding.
Output as a matrix:
| Page | EN | DE | FR | ES | JA | Parity |
|----------|----|----|----|----|----| ------ |
| /about | ✅ | ✅ | ✅ | ❌ | ✅ | 80/100 |
| /pricing | ✅ | ✅ | ⚠️ | ❌ | ❌ | 45/100 |
Cultural Adaptation Assessment
Go beyond translation: check whether content fits each target market. Flag as
Medium severity. Load references/cultural-profiles.md for prebuilt profiles.
- CTAs match cultural directness (e.g., aggressive "BUY NOW!" reads poorly in formal markets like ja-JP).
- Trust signals are locale-appropriate (local certifications, correct legal pages — e.g., DSGVO not CCPA on de-DE; High if wrong jurisdiction is cited).
- No foreign brand references or US-only statistics on localized pages.
- Currency/units match the market (no USD on EUR pages, no imperial on metric).
- No untranslated strings in nav, buttons, alt text, or schema.
Locale Format Validation
Mismatched formats (US date/number on a German page) signal weak localization and
erode trust. Load references/locale-formats.md for full tables.
- Numbers: de-DE
1.234,56, fr-FR1 234,56, en-US1,234.56. Flag US-format numbers on non-US pages. - Dates: de-DE
DD.MM.YYYY, en-USMM/DD/YYYY, ja-JPYYYY年MM月DD日. - Currency: symbol/placement per market (
1.234,56 €after with space on de-DE;$1,234.56before on en-US). - Phone: international format with correct country code.
In Next.js, format with Intl / next-intl rather than hardcoding, so locale
formatting follows the active locale:
import { useFormatter } from "next-intl";
// const f = useFormatter(); f.number(1234.56); f.dateTime(new Date());
Output
Hreflang Validation Report
Summary — pages scanned, locales detected, issues (Critical/High/Medium/Low), per-cluster Health Score, Parity Score.
Validation table
| Locale | URL | Self-Ref | Return Tags | x-default | Codes | Status |
|---|---|---|---|---|---|---|
| en-US | https://… | ✅ | ✅ | ✅ | ✅ | ✅ |
| fr | https://… | ❌ | ⚠️ | ✅ | ✅ | ❌ |
| de-DE | https://… | ✅ | ❌ | ✅ | ✅ | ❌ |
Generated fixes — corrected alternates.languages map, sitemap entries, or
HTTP header values, ready to paste.
Recommendations — missing implementations, codes to fix, and method-migration advice (e.g., metadata → sitemap for scale).
Reference Files
Load on-demand (do NOT load all at startup):
references/cultural-profiles.md— DACH, Francophone, Hispanic, Japanese profilesreferences/locale-formats.md— number/date/currency/address/phone tablesreferences/content-parity.md— parity audit methodology and scoringreferences/machine-translation-qa.md— MT quality gates (if present)
Error Handling
| Scenario | Action |
|---|---|
| URL unreachable (DNS/connection failure) | Report the error; do not guess structure. Ask the user to verify the URL. |
| No hreflang tags found | Report the absence; check other i18n signals (subdirs, subdomains, ccTLDs) and recommend the right method. |
| Invalid language/region codes | List each invalid code with its correct replacement and a corrected tag set. |
| Cultural profile missing for a language | Use the Default Profile checklist; note it is general guidance, not a prebuilt profile. |
| Content-parity directory empty | Report no files found; ask for the correct path or a live URL. |
Parts adapted from claude-seo (MIT, © 2026 agricidaniel).