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.
  • curl and a crawler (Screaming Frog, Sitebulb, or a scripted fetcher) that can extract and cross-check hreflang annotations 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.

Locale routing and hreflang emission flow A request is resolved to a locale at the edge, the page renders a self-referencing canonical and the full set of hreflang annotations, and the crawler receives them. Request /product or /fr/product Edge resolve locale prefix Render head self canonical + set Crawler reads annotations Explicit locale URLs are never redirected β€” only first-visit defaults are

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

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