Locale-Aware SEO & hreflang
A correctly localized application can still be invisible in every market but one. The pages exist, they are translated, they render at clean URLs — and search results keep showing the English page to German readers, or worse, show nothing at all because two localized URLs are competing and neither wins.
The signals that fix this are small in volume and precise in meaning. There are five of them, they answer five different questions, and almost every localization SEO problem is one of them being used to answer another one’s question.
This page belongs to Framework i18n & Component Routing because the annotations are a product of the routing decision: which URLs exist per locale determines everything a crawler can be told about them.
Prerequisites
Concept & spec — five signals, five questions
The canonical link answers which URL is authoritative for this page. It exists because the same content is often reachable at several URLs — with and without a trailing slash, with a tracking parameter, through a legacy path. Exactly one URL should be canonical, and on a localized site the canonical of the German page is the German URL. Pointing it at the English page is a request to remove the German page from the index.
hreflang annotations answer which URL serves which language. They are a set, not a single value, and they are only honoured when reciprocal: every page in a group must annotate every other page including itself. A page that lists alternatives without being listed by them is making an unverifiable claim, and search engines discard it.
The x-default entry answers where a reader whose language matches nothing should go. It is a member of the same annotation set, and its usual target is either a language selector or the default locale’s page.
The lang attribute answers what language is this text. It is not an SEO signal so much as an accessibility and rendering one — screen readers choose a voice from it, and browsers choose hyphenation and font fallbacks. It must reflect the language actually rendered, which means it changes when the locale does.
The Content-Language header answers what language is this response. It matters for caches and intermediaries rather than for crawlers, and it should agree with the lang attribute.
The mistake that costs the most
Canonicalising every localized page to the default locale’s URL is the single most damaging configuration in this area, and it is a natural mistake: the pages look like duplicates, and canonical is the tool for duplicates.
They are not duplicates. A translation is a different page serving a different audience, and a canonical annotation pointing from German to English says “index the English one instead”, which is exactly what happens. The German page then does not rank in Germany, and the English page ranks for German queries badly if at all.
The correct arrangement is that each localized page is its own canonical, and the relationship between them is expressed with hreflang — which is the annotation designed to say “these are alternatives for different audiences” rather than “these are the same thing”.
Step-by-step implementation
1. Generate the annotation set from the locale list
Hand-written annotations drift the moment a locale is added, and drift breaks reciprocity, which discards the whole set. Generate them.
type Locale = { code: string; language: string; };
const LOCALES: Locale[] = [
{ code: 'en', language: 'en-GB' },
{ code: 'de', language: 'de-DE' },
{ code: 'fr', language: 'fr-FR' },
];
const ORIGIN = 'https://example.com';
export function alternates(pathFor: (code: string) => string) {
const links = LOCALES.map((l) => ({
rel: 'alternate',
hreflang: l.language,
href: ORIGIN + pathFor(l.code),
}));
links.push({ rel: 'alternate', hreflang: 'x-default', href: ORIGIN + pathFor('en') });
return links;
}
Because every page in the group calls the same function with the same route, the set is identical everywhere and reciprocity holds without anyone maintaining it.
2. Make each page its own canonical
useHead({
link: [
{ rel: 'canonical', href: ORIGIN + currentLocalizedPath },
...alternates(localizedPathFor),
],
htmlAttrs: { lang: currentLanguageTag, dir: currentDirection },
});
3. Choose the right granularity for the language tag
hreflang accepts a language, or a language and a region. Use the plain language when one page serves every region that speaks it, and add the region only when genuinely different pages exist per region — different pricing, different legal text, different product availability. Annotating de-DE, de-AT and de-CH when all three serve one German page is a claim that three distinct pages exist, and it produces exactly the “alternate page with incorrect return tag” reports that follow.
4. Emit one sitemap per locale, or one sitemap with annotations
Both shapes are valid. A sitemap index pointing at one sitemap per locale is easier to reason about and to regenerate; a single sitemap carrying xhtml:link alternates per URL puts the annotation next to the URL it describes. What matters is that whichever you choose contains every localized URL and none that redirect.
<url>
<loc>https://example.com/de/about</loc>
<xhtml:link rel="alternate" hreflang="en-GB" href="https://example.com/about"/>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://example.com/de/about"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/about"/>
</url>
5. Do not redirect crawlers by language
An automatic redirect from /about to /de/about based on Accept-Language is convenient for readers and hostile to crawlers, which request from a small set of locations with a neutral header and will only ever see one language. Redirect on the bare root at most, keep every localized URL directly reachable, and let the annotations do the discovery — the same rule that keeps the redirect loops in locale negotiation strategies from forming.
Configuration reference
| Signal | Where it goes | Rule |
|---|---|---|
canonical |
<link> in the head |
Self-referencing per localized URL. Never points at another language. |
hreflang |
<link> set, or sitemap |
Reciprocal across the whole group, including a self-entry. |
x-default |
one entry in the same set | Points at the selector or the default locale. One per group. |
lang |
<html> attribute |
The BCP 47 tag of the text actually rendered. |
dir |
<html> attribute |
rtl for Arabic, Hebrew, Persian, Urdu; set from the locale list. |
Content-Language |
response header | Agrees with lang. Used by caches, not by ranking. |
| Region subtags | inside hreflang |
Only when genuinely different pages exist per region. |
Framework variants
Next.js App Router. generateMetadata returns alternates.languages and alternates.canonical, both derived from the route params. Because metadata generation runs per route, the natural implementation is a helper that takes the current path and returns the whole set, which keeps reciprocity automatic.
Nuxt. useLocaleHead generates the canonical, the alternates and the lang/dir attributes from the module’s locale list. It needs baseUrl set, or it emits relative URLs that crawlers ignore.
SvelteKit. There is no built-in generator, so the same helper lives in the root layout and reads from the resolved locale in load data. The prerender entries have to include every localized route, or the sitemap will list URLs that were never built — the trap covered in SvelteKit route prefixing for multiple locales.
Angular. With one build per locale, each build knows only its own locale, so the annotation set must come from shared configuration rather than from the runtime. Generating the head links at build time from the same locale list that drives the builds keeps them consistent.
Verification
# Every localized page is its own canonical
for p in "" de/ fr/; do
curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done
# Reciprocity: the set on the German page must match the set on the English page
diff <(curl -s https://example.com/about | grep -o 'hreflang="[^"]*"' | sort) \
<(curl -s https://example.com/de/about | grep -o 'hreflang="[^"]*"' | sort)
# (no output — the sets are identical, which is what reciprocity means)
A crawl-level check belongs in CI too: fetch every URL in the sitemap and assert that none of them redirect. A sitemap full of redirects is the most common reason a localized site indexes slowly, and it is trivially detectable.
Diagnosing an incorrect return tag report
The most common report a localized site receives says an alternate page has an incorrect return tag. It is worth understanding precisely, because the wording suggests a problem on the page being reported and the cause is almost always on the other one.
The claim being made is asymmetric. Page A lists page B as its German alternative. When the crawler fetches page B, it expects to find an annotation pointing back at page A. If that return link is absent, or points at a different URL, or is on a page that redirects, the pair is not confirmed and both annotations are discarded.
Four causes account for nearly all of these reports. A trailing-slash mismatch — A links to /de/about while B canonicalises to /de/about/ — makes the URLs unequal as strings even though both resolve. A protocol or host mismatch, usually http versus https or a www prefix present on one side, has the same effect. An annotation emitted only on some pages, typically because one template generates them and another does not, breaks reciprocity for whichever pages use the second template. And a redirecting alternate, where the annotation names a URL that 301s somewhere else, means the return link is never seen because the page itself is never fetched at that address.
All four are string-equality problems rather than semantic ones, which is why generating the whole set from one function fixes them permanently: the same generator produces the same string on both sides. Any pipeline that assembles annotations from more than one source will eventually disagree with itself about a slash.
When a report persists after the annotations are verified, fetch the alternate URL exactly as written in the annotation and follow no redirects. If that request does not return a 200 with the reciprocal link in its head, the crawler is seeing what your browser is hiding from you by following the redirect silently.
Common pitfalls
- Canonicalising translations to the default locale. Removes every localized page from the index. The most damaging single mistake in this area.
- One-way hreflang. A page listing alternatives that do not list it back has its whole set discarded.
- Relative annotation URLs. Ignored. Every
hrefmust be absolute, which is why an origin must be configured. - Region subtags without regional pages. Produces incorrect-return-tag reports and no benefit.
- Language-based redirects on every URL. Crawlers see one language and index one language.
- Sitemaps listing redirecting URLs. Wastes crawl budget and slows indexing of the pages that do exist.
- A
langattribute that never changes. Screen readers announce German text with an English voice, which is an accessibility failure independent of ranking.
Localized URLs, and whether to translate the path
Once the annotations are correct, the next question is usually whether the path segments themselves should be translated — /de/produkte rather than /de/products.
The argument for is straightforward: the URL is content, readers see it in results and in shared links, and a path in the reader’s language is more legible and more clickable. In markets where the English word is not widely understood, this is a real difference rather than a cosmetic one.
The arguments against are operational. Translated paths multiply the routing configuration, since every route now has a per-locale path that has to be maintained and kept in sync with the annotations. They make redirects harder when a path changes, because the change is per locale. And they interact badly with any tooling that assumes a stable path shape — analytics groupings, feature flags scoped by route, cache rules written against a prefix.
A middle position works well for most products: translate the paths of the small number of pages that are genuinely marketing surfaces, and leave application routes in English. A reader who has signed in and is looking at /de/settings/billing is not deciding whether to click, so the legibility gain is negligible, while the maintenance cost is identical.
Whatever the decision, it has to be made before the URLs are indexed, because changing a path later means a redirect plus a period during which the annotations point at the old form. That is recoverable, and it is a week of work nobody planned for.
FAQ
Should hreflang go in the head or in the sitemap?
Either, not both — duplicating them is not harmful but doubles the surface that can drift. Head links are easier to verify by looking at a page; sitemap annotations scale better for very large sites because they avoid adding markup to every page. Pick the one your generation pipeline can keep correct.
What should x-default point at?
A language selector if you have one, otherwise the default locale’s page. Its job is to name a sensible destination for a reader whose language matches none of your alternatives, and a selector serves that better than guessing.
Do I need hreflang if I only have one language?
No. The annotations describe alternatives, and with a single language there are none. A canonical link and a correct lang attribute are still worth having.
Does a locale cookie affect indexing?
It should not, and that is the point. Content that varies by cookie is content a crawler cannot see, so the language must be determined by the URL. A cookie that changes what a URL renders makes the indexed version arbitrary.
How do regional variants interact with the fallback chain?
They are independent. The fallback chain decides which bundle backs a string at runtime, described in fallback chain configuration; hreflang describes which URL serves which audience. A site can serve one German page while annotating it as de — what it must not do is annotate three regional variants that resolve to the same URL.
Should a language selector be crawlable?
Yes, and it is worth building it as ordinary links rather than as a script-driven control. A selector rendered as real anchors gives crawlers a discoverable path to every locale of the current page, which is a useful supplement to the annotations — particularly on a large site where a crawler may reach one localized page long before it reaches the sitemap entry for its siblings. A selector that only works through JavaScript, or that posts a form, provides none of that.
Does serving different content per region hurt ranking?
Not in itself, provided each variant has its own URL and the annotation set describes them honestly. What causes problems is regional variation without distinct URLs — the same address returning different prices or legal text depending on the requester — because then the indexed version is whichever the crawler happened to receive, and it may be the wrong one for every reader who later finds it.
How long does an annotation change take to have an effect?
Longer than a deploy, and unevenly across a site. Annotations are re-evaluated when a page is recrawled, so heavily crawled pages update within days and rarely crawled pages can lag by weeks. That asymmetry is worth remembering when validating a fix: a site is not “still broken” because a rarely visited page has not caught up yet, and forcing recrawls of everything is rarely the best use of the effort.
Related
- Canonical pointing to the default locale — the one line that asks search engines to drop every translated page.
- x-default missing or pointing at a redirect — why the natural destination is the one URL that cannot serve it.
- Regional variants: de-AT versus de-DE — when a regional subtag is a claim you can actually back with a page.
- Nuxt i18n Module Setup — a module that generates most of these signals for you.
- Next.js i18n Routing Setup — where the equivalent metadata is produced in the App Router.
- SvelteKit route prefixing for multiple locales — prerendering every localized URL a sitemap will claim.
- Locale Negotiation Strategies — why redirection has to stay off localized URLs.
- RTL & Bidirectional Layout Engineering — the
dirattribute these annotations sit beside.
Part of Framework i18n & Component Routing.