**TL;DR.** Hreflang tells search engines which locale variant to serve to each user. Get four things right: (1) complete reciprocal clusters with self-references, (2) correct ISO language codes, (3) x-default fallback, (4) per-entity gating so you don't advertise non-existent translations. Anything else is implementation detail.

## What hreflang does

Hreflang is an HTML attribute (and equivalent sitemap annotation) that signals to Google which language and region a page targets. Properly implemented hreflang:

- Serves Spanish users your Spanish URL, not your English one.
- Prevents your French URL from outranking your German URL in Germany.
- Consolidates link equity across locale variants instead of treating them as duplicate content.

Hreflang does not boost rankings. It controls which locale variant ranks where.

## Step 1: Decide where to emit hreflang

Three options:

| Method               | Pros                                          | Cons                                         |
| -------------------- | --------------------------------------------- | -------------------------------------------- |
| HTML head            | Easy to debug; one source per page             | Adds bytes to every HTML response             |
| HTTP `Link` header   | Works for non-HTML resources (PDFs, etc.)      | Hard to debug; rarely needed                  |
| XML sitemap          | Most compact for huge catalogs                  | Less visible to humans; needs sitemap discipline |

The 2026 default for most ecommerce sites is **HTML head**. Switch to sitemap-only above 50,000 URLs.

## Step 2: Emit a complete cluster

For every translated URL, output hreflang for itself and every other variant.

A 4-locale cluster (en, de, fr, es) for a product:

```html
<link rel="alternate" hreflang="en" href="https://example.com/en/products/leather-bag" />
<link rel="alternate" hreflang="de" href="https://example.com/de/products/leather-bag" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/products/leather-bag" />
<link rel="alternate" hreflang="es" href="https://example.com/es/products/leather-bag" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/products/leather-bag" />
```

This cluster appears in the head of **every** locale variant of this product. The German URL emits the same cluster, the French URL emits the same cluster, etc.

The cluster must be reciprocal — A points to B, B points to A. Non-reciprocal clusters get silently ignored by Google.

## Step 3: x-default

`x-default` is the fallback for users whose browser language doesn't match any of your variants. Typically your English (or default-locale) URL.

```html
<link rel="alternate" hreflang="x-default" href="https://example.com/en/products/leather-bag" />
```

The same URL can be both `hreflang="en"` and `hreflang="x-default"`. Only one URL in the cluster gets `x-default`.

## Step 4: Filter to actually-translated entities

The biggest practical mistake: emitting a 9-locale cluster on a product that's only translated to 3 locales. The advertised URLs return 404; Google sees the broken cluster and may ignore the entire site's hreflang.

The fix: per-entity `availableLocales`.

```ts
// Pseudocode
interface ProductForSeo {
  slug: string;
  availableLocales: string[];  // e.g. ["en", "de", "fr"]
}

interface StoreForSeo {
  supportedLocales: string[];  // e.g. ["en", "de", "fr", "es", "it"]
  defaultLocale: string;
}

function buildHreflangCluster(product: ProductForSeo, store: StoreForSeo) {
  const effective = product.availableLocales.filter((l) =>
    store.supportedLocales.includes(l)
  );
  return effective.map((locale) => ({
    hreflang: locale,
    href: `https://example.com/${locale}/products/${product.slug}`,
  }));
}
```

Ordiko's `src/lib/seo/storefront.ts:316` implements this exact intersection.

## Step 5: Correct language codes

Use **ISO 639-1** language codes:

| Language                 | Code      |
| ------------------------ | --------- |
| English                  | `en`      |
| Spanish                  | `es`      |
| French                   | `fr`      |
| German                   | `de`      |
| Portuguese               | `pt`      |
| Italian                  | `it`      |
| Russian                  | `ru`      |
| Ukrainian                | `uk`      |
| Chinese (Simplified)     | `zh-Hans` |
| Chinese (Traditional)    | `zh-Hant` |
| Japanese                 | `ja`      |
| Korean                   | `ko`      |
| Arabic                   | `ar`      |

For language + region (when you serve a locale-region variant):

| Variant                  | Code      |
| ------------------------ | --------- |
| US English               | `en-US`   |
| UK English               | `en-GB`   |
| Canadian English         | `en-CA`   |
| Mexico Spanish           | `es-MX`   |
| Spain Spanish            | `es-ES`   |
| France French            | `fr-FR`   |
| Canada French            | `fr-CA`   |
| Brazil Portuguese        | `pt-BR`   |
| Portugal Portuguese      | `pt-PT`   |

Don't use:

- `en-EN` (invalid)
- `gb` (use `en-GB`)
- `cn` (use `zh-Hans`)
- `kr` (use `ko`)

## Step 6: Validate

Three checks:

1. **Self-reference**: every URL must include itself in its own cluster.
2. **Reciprocity**: every variant in the cluster must include every other variant.
3. **Reachability**: every URL in the cluster must return 200.

Tools:

| Tool                            | What it checks                              |
| ------------------------------- | ------------------------------------------- |
| Google Search Console           | Real-world hreflang errors over time         |
| [hreflang.org/validator](https://hreflang.org) | Cluster correctness                    |
| Sitebulb / Screaming Frog       | Reciprocity and self-reference              |
| Ahrefs / Semrush                | Hreflang reports in site audit              |
| Manual `curl`                   | Reachability per URL                        |

Vitest test pattern for CI:

```ts
import { describe, it, expect } from 'vitest';

describe('hreflang cluster', () => {
  it('every variant URL returns 200', async () => {
    const cluster = [
      'https://example.com/en/products/x',
      'https://example.com/de/products/x',
      'https://example.com/fr/products/x',
    ];
    for (const url of cluster) {
      const res = await fetch(url);
      expect(res.status).toBe(200);
    }
  });

  it('every variant emits self-referencing hreflang', async () => {
    for (const url of cluster) {
      const html = await fetch(url).then((r) => r.text());
      expect(html).toContain(`hreflang="${getLocale(url)}" href="${url}"`);
    }
  });
});
```

## Special cases

**Single-page apps**: emit hreflang server-side in the document head. Don't rely on client-side injection — Google's crawler may not execute your JS.

**ccTLDs**: hreflang still applies. `example.de` can specify `hreflang="de-DE"` for itself and link to `example.com/en/...` for `en`.

**Region without language difference**: if you serve `en-US` and `en-GB` with identical content, hreflang differentiates them. Don't share the URL — give each its own URL even if content is identical.

## Common pitfalls

| Mistake                                  | Effect                                              |
| ---------------------------------------- | --------------------------------------------------- |
| Non-reciprocal cluster                   | Google ignores hreflang entirely                    |
| Variant URL returns 404                   | Google ignores the URL; may demote cluster          |
| Wrong language code                       | Variant treated as untargeted                       |
| Multiple x-default URLs                   | One is ignored arbitrarily                          |
| Hreflang only on canonical URL           | Sub-pages have no hreflang signal                   |
| Mixing absolute and relative URLs         | Use absolute URLs always                            |
| Different domain per locale without consistent hreflang | Cluster breaks                       |

## How Ordiko emits hreflang

`src/lib/seo/storefront.ts` computes `alternates.languages` for every Next.js page metadata object. The function takes:

- `store.supportedLocales`
- `entity.availableLocales` (optional, defaults to store.supportedLocales)
- `store.defaultLocale` (used for x-default)

And emits the intersection cluster in HTML head. Self-reference, reciprocity, and x-default are guaranteed by the implementation. Per-entity gating is opt-in via the `availableLocales` field on the entity loader.

## FAQ

**HTML head or sitemap — which is better?**
HTML head is easier to debug because you can verify with View Source. Sitemap is more compact for stores with 50k+ URLs because hreflang is emitted once per URL pair, not in every page's head. Pick HTML for stores under 50k URLs; sitemap above that. Ordiko emits HTML head by default.

**What language code do I use for Latin American Spanish vs Spain Spanish?**
es-MX for Mexico, es-AR for Argentina, es-CO for Colombia, es-ES for Spain. The language-region combination is supported. For a single Latin American locale serving multiple countries, es-419 (the UN code for Latin America and Caribbean) is valid but less widely supported.

**Can I have multiple x-default URLs?**
No. One x-default per cluster. If your business serves English globally, the .com (or your default-locale URL) is the x-default. The same URL can be both hreflang='en' and hreflang='x-default'.

**How does Ordiko handle hreflang correctness?**
Ordiko's storefront SEO library (src/lib/seo/storefront.ts) intersects each entity's availableLocales with the store's supported locales when emitting alternates.languages. A product translated only to en + de will only advertise those two locales, regardless of the store's wider locale set.
