hreflang & International Routing in Headless
Multi-locale headless sites generate near-identical HTML across languages and regions, so without correct hreflang and locale routing Google treats those variants as duplicates and serves the wrong one to the wrong audience. This section covers how to address locales, emit a complete reciprocal hreflang set, and route at the edge without creating the redirect loops that suppress international indexing.
Prerequisites
Before implementing hreflang, confirm these are in place:
- A multi-locale content model in your CMS where each entry knows its own locale and the URLs of its sibling translations.
- A chosen locale addressing scheme β subpath (
/fr/), subdomain (fr.example.com), or ccTLD β decided before you write any routing code. - Edge runtime access (Cloudflare Workers, Vercel Edge Middleware, or Netlify Edge Functions) for first-visit locale detection.
curland a crawler (Screaming Frog, Sitebulb, or a scripted fetcher) that can extract and cross-checkhreflangannotations across every variant.- Consistent locale codes β valid ISO 639-1 language and optional ISO 3166-1 region codes (e.g.
en,en-GB,pt-BR), matching what the CMS stores.
Because locales multiply the URL surface, canonical URL enforcement must already be reliable β a locale that self-canonicalises incorrectly will contradict its hreflang annotations and drop out of the index.
How a Locale Request Resolves to Annotated HTML
The diagram shows a request flowing through edge locale resolution into a page that emits a self-referencing canonical plus the full hreflang set before the crawler reads it.
Step-by-Step Implementation Workflow
Step 1 β Choose a URL locale pattern
Decide how locales appear in the URL and apply it everywhere. Subpaths are the least operationally expensive on a single headless domain.
subpath example.com/fr/product (recommended: one domain, shared authority)
subdomain fr.example.com/product (separate hosting / signals)
ccTLD example.fr/product (strong country signal, highest overhead)
Whichever you pick, apply slug normalization strategies per locale so the path segment after the locale prefix stays consistent.
Step 2 β Build the locale set from the CMS model
Generate the complete list of locale variants for a page from CMS data, not from hardcoded assumptions, so adding a locale does not require a code change.
// lib/hreflang.ts
type Variant = { locale: string; path: string };
export function buildAlternates(variants: Variant[], base: string) {
const links = variants.map((v) => ({
hrefLang: v.locale, // e.g. "en-GB", "fr", "pt-BR"
href: `${base}${v.path}`,
}));
// x-default points at the locale-selector or default-locale URL
const def = variants.find((v) => v.locale === 'en') ?? variants[0];
links.push({ hrefLang: 'x-default', href: `${base}${def.path}` });
return links;
}
Step 3 β Self-reference the canonical per locale
Every locale variant declares a canonical to itself. Never canonicalise a locale to the default language β that removes it from the index.
// each variant's canonical == its own absolute URL
const canonical = `${base}${currentVariant.path}`;
Step 4 β Route locales at the edge without loops
Detect locale on first visit and redirect only requests that have no explicit locale. Leave explicit locale URLs untouched so crawlers never hit a redirect.
// middleware.ts (Vercel Edge)
import { NextRequest, NextResponse } from 'next/server';
const LOCALES = ['en', 'fr', 'de', 'pt-BR'];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Explicit locale already present β do nothing (crawler-safe)
if (LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)) {
return NextResponse.next();
}
// Only redirect the locale-less root/default entry points
const pref = req.headers.get('accept-language')?.split(',')[0] ?? 'en';
const match = LOCALES.find((l) => pref.toLowerCase().startsWith(l.toLowerCase())) ?? 'en';
const url = req.nextUrl.clone();
url.pathname = `/${match}${pathname}`;
return NextResponse.redirect(url, 302); // 302: user preference, not permanent
}
Step 5 β Validate reciprocity
Crawl every variant and confirm each annotation is reciprocated, resolves to 200, uses absolute URLs, and includes an x-default. See the Validation Protocol below.
Framework-Specific Code Examples
Next.js App Router
Emit the hreflang set through generateMetadata so the annotations are in the server-rendered <head>, not injected client-side.
// app/[locale]/product/[slug]/page.tsx
import type { Metadata } from 'next';
import { buildAlternates } from '@/lib/hreflang';
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 base = process.env.NEXT_PUBLIC_SITE_URL!;
const languages = Object.fromEntries(
buildAlternates(variants, base).map((l) => [l.hrefLang, l.href])
);
const self = `${base}/${locale}/product/${slug}`;
return { alternates: { canonical: self, languages } };
}
SEO impact: alternates.languages renders <link rel="alternate" hreflang="..."> tags plus the self-canonical into the initial HTML, so Google reads a complete, reciprocal set on first fetch.
Validation: curl -s https://example.com/fr/product/x | grep -i 'alternate\|canonical' β confirm every locale plus x-default is present and the canonical points to the /fr/ URL.
SvelteKit
Return the alternates from a server load and render them in <svelte:head>.
<!-- src/routes/[locale]/product/[slug]/+page.svelte -->
<script lang="ts">
export let data; // { canonical, alternates }
</script>
<svelte:head>
<link rel="canonical" href={data.canonical} />
{#each data.alternates as a}
<link rel="alternate" hreflang={a.hrefLang} href={a.href} />
{/each}
</svelte:head>
SEO impact: Because +page.server.ts runs on the server (or at prerender time), the full annotation set is written into static HTML β see edge caching behavior for SEO considerations when caching per-locale responses.
Validation: Prerender a locale route and open the built .html file; every hreflang link must be present in the static markup.
Nuxt 3
Use useHead with a link array so annotations render server-side.
<!-- pages/[locale]/product/[slug].vue -->
<script setup lang="ts">
const { locale, slug } = useRoute().params as Record<string, string>;
const { data } = await useFetch(`/api/variants/${slug}`);
const base = useRuntimeConfig().public.siteUrl;
useHead({
link: [
{ rel: 'canonical', href: `${base}/${locale}/product/${slug}` },
...data.value.alternates.map((a: any) => ({
rel: 'alternate', hreflang: a.hrefLang, href: a.href,
})),
],
});
</script>
SEO impact: useRuntimeConfig().public.siteUrl resolves the absolute base at server startup, keeping locale URLs absolute and preventing relative-URL hreflang errors.
Validation: Fetch the rendered page and confirm the <link rel="alternate"> set matches the CMS locale model exactly.
HTTP Headers & CDN Directives Reference
| Header / signal | Required value | Rationale |
|---|---|---|
Content-Language |
the page locale, e.g. fr-FR |
Reinforces the served locale for clients and some crawlers |
Link (alternate) |
<https://example.com/fr/x>; rel="alternate"; hreflang="fr" |
Delivers hreflang at the HTTP layer for non-HTML documents like PDFs |
Vary |
Accept-Language only if you vary content by header |
Prevents caches from serving one locale to another; omit if you route by URL |
Cache-Control |
per-locale cache key | Ensures a locale variant is not served from another localeβs cached entry |
Location (302) |
absolute locale URL | First-visit locale redirects should be 302, not 301, since they reflect user preference |
Validation Protocol
Extract and diff the annotation set
# List the hreflang annotations a page emits
curl -s https://example.com/fr/product/x \
| grep -oiP 'hreflang="[^"]+"' | sort -u
# Expect: every locale in your CMS model plus hreflang="x-default"
Confirm reciprocity and 200 status
# For each alternate, follow it and confirm it points back and returns 200
for u in $(curl -s https://example.com/fr/product/x \
| grep -oiP '(?<=alternate" href=")[^"]+'); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$u")
back=$(curl -s "$u" | grep -c 'hreflang="fr"')
echo "$code reciprocal=$back $u"
done
# Every line: 200 and reciprocal>=1
Confirm the self-canonical matches the locale
curl -s https://example.com/fr/product/x \
| grep -oiP '(?<=canonical" href=")[^"]+'
# Must return the /fr/ URL, never the default-locale URL
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Wrong-language page ranks for a query | hreflang set incomplete or not reciprocated | Emit every sibling locale plus x-default on all variants and verify back-references |
| Locale variant dropped from index | Variant canonicalises to the default locale | Self-reference each localeβs canonical; align it with the hreflang set |
| Googlebot hits redirect loops on locale URLs | Edge redirects explicit locale URLs | Redirect only locale-less entry points; leave /fr/β¦ requests untouched |
| hreflang βno return tagsβ error in GSC | Annotation URLs differ (slash/protocol) from served URLs | Use absolute, normalized URLs identical to the served canonical URLs |
| Duplicate locale entries inflate index count | Locale variants indexed as separate duplicates | Confirm reciprocal hreflang and consistent slugs; see indexation limits for decoupled sites |
x-default missing or wrong |
No fallback declared | Point x-default at the locale selector or default-locale URL on every page |
Pages in This Section
- Implementing hreflang in Headless Multi-Locale Sites β generate the full reciprocal locale set plus x-default at render time and validate reciprocity end to end
Frequently Asked Questions
Must every locale page list all other locales in hreflang?
Yes. Annotations must be reciprocal and complete: every locale variant lists every other variant, including itself, plus an x-default. If page A points to page B but B does not point back to A, Google ignores the unreciprocated pairing β so one missing back-reference silently breaks the whole set.
Subpath or subdomain for locales?
Subpaths such as /fr/ are simplest on a headless stack: one domain, one certificate, shared authority, and edge routing by path prefix. Subdomains or ccTLDs make sense when locales need separate hosting, legal separation, or strong country-level signals, but they add operational overhead and split link equity.
Does hreflang replace canonical? No β they solve different problems and must agree. Each locale variant self-references its own canonical, and the hreflang set tells Google those self-canonical variants are alternates. A canonical pointing from a locale to the default locale contradicts hreflang and drops the locale from the index. See canonical URL enforcement for the enforcement pattern.
How should non-Latin locale slugs be handled? Transliterate or localise the slug per locale upstream of normalization so each locale has a stable, crawlable path β never reuse one localeβs slug for another. This coordinates with slug normalization strategies so the segment after the locale prefix stays consistent across builds.
Part of: Dynamic Routing & Indexation Workflows
Related
- Canonical URL Enforcement β keep per-locale self-canonicals consistent with the hreflang set
- Slug Normalization Strategies β normalize the path segment after the locale prefix for every language
- Indexation Limits for Decoupled Sites β prevent locale variants from multiplying into indexation bloat
- Redirect Chain Management β avoid stacked locale redirects that create crawlable chains
- Debugging & Diagnostics for Headless Routing β diagnose locale variants that fail to index after launch