Implementing hreflang in Headless Multi-Locale Sites
Emit a reciprocal, complete hreflang set for every locale variant at render time so Google serves the right language version without treating translations as duplicate content.
When to Use This Approach
Apply this pattern when your headless build serves more than one language or region variant and any of the following are true:
- Google Search Console reports locale variants as duplicates, or you see the wrong-language page ranking for a regional query.
- Your locale URLs follow a subpath (
/fr/) or subdomain pattern, and translations are managed as linked entries in the CMS rather than as unrelated pages. - hreflang annotations are currently injected by client-side JavaScript, so crawlers that do not execute scripts never see the reciprocal set.
Implementation Steps
Step 1: Model Locale Variants in the CMS
Every hreflang annotation names a URL, so each page must know the URLs of all its translations. Model this as a shared translation group in the CMS: link every locale variant to a common identifier so a single query returns the whole set. If translations are stored as unrelated entries, you cannot build a reciprocal set reliably.
# Query the full translation group for one entry
query LocaleVariants($groupId: ID!) {
article(groupId: $groupId) {
variants {
locale # e.g. "en", "en-GB", "fr", "de"
slug # normalized, locale-specific slug
isDefault # marks the x-default source
}
}
}
Validation: Fetch any entry and confirm variants returns every locale you publish, with exactly one entry flagged isDefault. A group missing a locale will silently omit that page from the hreflang set.
Step 2: Generate the Full hreflang Set Server-Side
Build the complete list of annotations — every locale plus a single x-default — from the variant group at render time. Producing this server-side guarantees crawlers see it without executing JavaScript.
// lib/hreflang.ts
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? '';
type Variant = { locale: string; slug: string; isDefault: boolean };
export function buildHreflang(variants: Variant[]) {
const alternates = variants.map((v) => ({
hreflang: v.locale,
href: `${BASE}/${v.locale}/${v.slug}`,
}));
const fallback = variants.find((v) => v.isDefault) ?? variants[0];
alternates.push({
hreflang: 'x-default',
href: `${BASE}/${fallback.locale}/${fallback.slug}`,
});
return alternates;
}
Validation: For a page with four locales, buildHreflang must return five entries (four locales plus x-default). Assert the count in a unit test so a dropped variant fails the build.
Step 3: Self-Canonical Each Locale
hreflang groups distinct URLs; it does not merge them. Each locale page must therefore point its canonical at its own absolute URL. A cross-locale canonical (for example, every language canonicalizing to /en/) tells Google to drop the other locales entirely. Keep this aligned with your canonical URL enforcement policy.
// lib/canonical-locale.ts
export function localeCanonical(locale: string, slug: string): string {
const base = process.env.NEXT_PUBLIC_SITE_URL ?? '';
return `${base}/${locale}/${slug}`;
}
Validation: curl -s https://example.com/fr/guide | grep -i canonical must return href="https://example.com/fr/guide" — the locale’s own URL, never the default locale.
Step 4: Output Annotations in the Head or an HTTP Header
Emit the annotations in the server-rendered <head> for HTML pages. Use Link response headers only when you cannot inject head tags. Do not do both for the same URL.
// app/[locale]/[slug]/page.tsx
import type { Metadata } from 'next';
import { buildHreflang } from '@/lib/hreflang';
import { localeCanonical } from '@/lib/canonical-locale';
import { getVariants } from '@/lib/cms';
export async function generateMetadata(
{ params }: { params: Promise<{ locale: string; slug: string }> },
): Promise<Metadata> {
const { locale, slug } = await params;
const variants = await getVariants(slug);
const languages = Object.fromEntries(
buildHreflang(variants).map((a) => [a.hreflang, a.href]),
);
return {
alternates: {
canonical: localeCanonical(locale, slug),
languages,
},
};
}
Validation: curl -s https://example.com/fr/guide | grep -i 'alternate' must list every locale and one x-default line, all as absolute URLs in the raw HTML.
Step 5: Validate Reciprocity and Return Codes
hreflang only takes effect when the annotations are reciprocal and every annotated URL returns 200. Crawl the whole set and assert both conditions.
# Confirm every locale URL is 200 and lists all siblings
for url in \
https://example.com/en/guide \
https://example.com/fr/guide \
https://example.com/de/guide ; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
count=$(curl -s "$url" | grep -c 'rel="alternate" hreflang')
echo "$code alternates=$count $url"
done
# Expect: 200 alternates=4 for each (3 locales + x-default)
Validation: Any line that is not 200 or lists a different alternate count than its siblings is a reciprocity break. A 301 on an annotated URL means an annotation points at a redirecting address — fix the source URL, not the redirect.
SEO Impact Summary
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Locale targeting | Google serves the correct language variant in each market | Missing reciprocity makes Google ignore hreflang and pick a variant itself |
| Duplicate handling | Translations are grouped as alternates, not duplicates | A cross-locale canonical drops every non-canonical locale from the index |
| Fallback coverage | x-default catches unmatched languages and regions |
No x-default leaves unmatched users on an arbitrary locale |
| Crawl efficiency | Server-rendered annotations are read on first fetch | Client-injected hreflang is invisible to non-rendering crawls, wasting recrawls |
Measurable signals to watch:
- GSC International Targeting / hreflang report: “no return tags” errors should fall to zero within a few crawl cycles.
- Wrong-language impressions in Search Console should decline as the correct variant starts ranking per market.
- Indexed locale count should match the number of published variants, with no locale collapsed under another.
Edge Cases and Gotchas
Region codes must be valid, and language is not region.
hreflang="en-GB" targets English speakers in the United Kingdom; hreflang="gb" is invalid because gb is not a language code. Use ISO 639-1 language codes optionally suffixed with an ISO 3166-1 Alpha-2 region. When in doubt, ship the language-only code (en, fr) and add region variants only where content genuinely differs.
Non-Latin slugs need transliteration before the URL is built.
If locales use non-Latin scripts, the per-locale slug must be normalized consistently or the hreflang href will not match the served URL. Pair this pattern with handling multi-locale slug transliteration so each locale’s slug is stable and reciprocal.
Edge locale redirects must not break annotated URLs.
Auto-redirecting /guide to /fr/guide by Accept-Language is fine for humans, but the annotated URLs themselves must resolve directly with 200. If Googlebot requests /de/guide and the edge redirects it by detected language, the annotation is effectively broken. Exempt paths that already carry a locale prefix from geo-redirect logic.
Incremental builds can serve a stale hreflang set. When one locale is republished under incremental static regeneration, its siblings may still hold the old set until they are rebuilt too. Revalidate the whole translation group together, or the reciprocity check in Step 5 will intermittently fail. This interacts with how indexation limits for decoupled sites constrain how many locale URLs are recrawled per window.
Frequently Asked Questions
Does each page need to reference all locales?
Yes. Every locale variant must list the full set of alternates, including a self-reference and x-default. hreflang is only honored when the annotations are reciprocal: if page A points to B but B does not point back to A, Google discards the pairing. Generating the set from a shared translation group, as in Step 1, keeps every page’s list complete and symmetrical.
What is x-default for?
x-default marks the fallback URL served to users whose language or region has no dedicated variant. It is typically the locale selector, the primary-market page, or a geo-redirect landing page. Include exactly one x-default in every hreflang set — omitting it leaves unmatched users on whichever variant Google guesses.
Should hreflang go in the head or an HTTP header?
For HTML pages, rel="alternate" link tags in the server-rendered head are simplest and easiest to audit. Use Link response headers when you cannot inject head tags, or for non-HTML resources such as PDFs. Never mix both methods for the same URL, and never rely on client-injected tags that non-rendering crawls will miss.
Does hreflang replace the canonical tag? No. They solve different problems and must coexist. Each locale self-canonicalizes to its own URL, and hreflang then groups those canonical URLs as language alternates. Removing the self-canonical to “let hreflang handle it” causes Google to collapse the locales — keep both, aligned through canonical URL enforcement.
Part of: hreflang & International Routing in Headless
Related
- Canonical URL Enforcement — self-canonical each locale so hreflang groups pages instead of collapsing them
- Handling Multi-Locale Slug Transliteration — produce stable per-locale slugs so hreflang hrefs match served URLs
- Indexation Limits for Decoupled Sites — plan how many locale URLs can be recrawled and indexed per window