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.

hreflang generation pipeline A four-stage pipeline showing a locale request resolved at the edge, the full hreflang set and x-default built server-side, each locale self-canonicalized, and the annotated page served to the crawler. Resolve locale edge / route param Build hreflang set all locales + x-default Self-canonical per locale URL Serve to crawler head or Link header en, en-GB, fr, de, x-default

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