--- createdAt: 2026-08-29 updatedAt: 2026-08-29 title: "Hreflang, guide for multilingual SEO" description: "What hreflang is, the rules search engines enforce, why x-default is almost always wrong, and how to generate correct tags in Next.js and TanStack Start." keywords: - hreflang - SEO - Internationalization - Intlayer - i18n - Sitemap - Canonical - Next.js - TanStack Start slugs: - blog - hreflang-guide-multilingual-seo author: aymericzip --- # Hreflang: the guide for multilingual SEO You translated your app. You shipped `/en`, `/fr`, `/es`. And French users still land on the English page. Translating is the easy half. The hard half is telling search engines that these pages are the **same page in another language**, not three documents competing with each other. That is what `hreflang` does, and it is where most multilingual sites quietly lose their traffic. --- ## What hreflang actually is An annotation on a page saying: _this URL has equivalent versions over there, for those languages._ ```html ``` It buys you two things: the right version shown to the right user, and your locales consolidated into one cluster instead of cannibalising each other as duplicates. It is worth being clear about what it is not. It is **not a redirect** — it is a hint, and Google may override it. It is **not a ranking boost** — it changes _which_ version ranks, not _whether_ you rank. And Bing ignores it entirely, relying on `content-language` and geo-targeting instead. --- ## Where to declare it Three placements, all valid. Pick one and stay there — the same cluster declared in two places is how sets drift apart. **HTML `
`** is the usual choice. One caveat: tags injected after hydration are unreliable. If your framework only adds them client-side, the crawler may never see them. **XML sitemap** is better at scale. Ten locales across 5 000 pages means 50 000 `` elements shipped to browsers for nothing; in a sitemap it costs your pages zero bytes. **HTTP `Link` header** is the only option for non-HTML files like PDFs. --- ## The rules ### Self-reference and reciprocity The set on `/fr/about` must include `hreflang="fr"` pointing at `/fr/about`. And if `/about` points at `/fr/about`, `/fr/about` must point back. Google calls a one-way reference a "no return tag" and drops it. In practice this means **every page in a cluster ships the identical set of links**. Generating them from one shared locale list is not a convenience, it is the only way to stay correct once you have more than two locales. ### Absolute URLs, always ```html ``` The reason is worth understanding rather than memorising. `hreflang` is a cross-document reference: search engines build a cluster keyed by URL, shared across every page in it. A relative path only has meaning relative to the document it sits in, so it cannot express that. It also cannot cross a host — and an alternate very often does, when a locale lives on `example.fr` or `fr.example.com`. In a sitemap or an HTTP header there is no base document to resolve against at all. This has a direct consequence in code. `getLocalizedUrl("/about", "fr")` returns `/fr/about` — relative in, relative out. For `hreflang` you must feed it an absolute URL: ```ts getLocalizedUrl("/about", "fr"); // → "/fr/about" ❌ dropped getLocalizedUrl("https://example.com/about", "fr"); // → "https://example.com/fr/about" ✅ ``` The one exception is a framework that resolves relative values for you before rendering: Next.js expands relative `alternates` against `metadataBase`. Fine — but the rule applies to the **emitted HTML**, so check with `curl`, not the DevTools inspector. ### Language codes ISO 639-1 for the language, ISO 3166-1 Alpha 2 for the optional region: `fr`, `fr-CA`, `pt-BR`. Two traps catch almost everyone. A region alone is invalid — `hreflang="ca"` is Catalan, not Canada; you need `en-CA` or `fr-CA`. And `en-UK` does not exist: the country code for the United Kingdom is `GB`, so it is `en-GB`. Only add a region when you genuinely serve that region different content — different prices, different legal notices. `fr` and `fr-FR` on identical content is noise. ### x-default ```html ``` One concept that is the most frequently forgotten, and badly understood, is `x-default` — less than 30% of apps implement it properly. It is the fallback for users whose language matches nothing in your set. A Dutch speaker on a site offering English, French, and Spanish matches no entry; without `x-default`, Google picks for you. What people get wrong is what it means. `x-default` is **not "the English version"** and **not "the default locale"**, even though it usually points there. It means _the page for users this set does not cover_. That is why it is legitimate — and often better — to point it at a language-selector or geo-redirecting landing page rather than at `/en`. If you have no such page, your primary language is the sensible answer. Two things to keep straight: `x-default` is one extra entry in the set, not a replacement for the self-referencing one, and like every other entry it must appear identically on every page in the cluster. --- ## The canonical trap Each localized page must be **its own canonical**: ```html ``` Pointing every locale's canonical at the English version instead: ```html ``` says the French page is a duplicate that should not be indexed, while `hreflang` says it is the page to serve French users. The signals contradict, canonical wins, and your French pages fall out of the index. **Canonical is self-referential per locale. `hreflang` describes the cluster.** --- ## Choosing a URL structure `hreflang` annotates URLs, so the structure comes first. | Structure | Example | Trade-off | | ------------------ | ----------------- | ---------------------------------------------------------- | | **Subdirectories** | `example.com/fr/` | One domain, shared authority — weaker geo-signal | | **Subdomains** | `fr.example.com` | Easy to add or drop a locale — may read as a separate site | | **ccTLDs** | `example.fr` | Strongest country signal — authority built per domain | Subdirectories are the right default for most projects. Reach for ccTLDs only when you really operate as separate country businesses. The one structure to avoid: serving different languages at the **same URL** based on `Accept-Language` or IP. Crawlers see one version and index one version; everything else is invisible. > Intlayer covers all three through `routing.mode` and `routing.domains`. See [custom domains](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/custom_domains.md) and the [configuration reference](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/configuration.md). --- ## Implementation Hand-writing these tags does not survive contact with a second locale. Derive them from your locale list instead.