Duplicate hreflang in Docusaurus? Built-in i18n emits them
Running an SEO audit on a multilingual site, you read the page source and find every <link rel="alternate" hreflang="..."> appearing twice — two full hreflang sets coexisting, with overlapping language codes.
Encountered this while building CCLEE Docusaurus Theme — a documentation theme built on Docusaurus 3.x with multilingual and SEO infrastructure baked in; hreflang is exactly the kind of thing it must get right natively.
Symptom: two hreflang sets in the head
In the page head, the same locale's alternate link appears twice — one set from Docusaurus's built-in i18n, one from a custom injector. Each set is individually correct; together they conflict. One-line self-check:
curl -s https://your-site.com/ | grep -o 'hreflang="[^"]*"' | sort | uniq -c
Any locale code (including x-default) appearing more than once means duplication. If you searched for "duplicate hreflang", "hreflang emitted twice" or "Docusaurus two sets of hreflang" — same issue.
Root cause: built-in i18n already generates them; the plugin adds a second set
Once i18n.locales is configured, Docusaurus's built-in i18n automatically emits complete hreflang alternates (including x-default) for every page — out of the box, no plugin involved. This site's custom SEO plugin, however, also had hreflang injection logic, so every page received two sets:
- Built-in i18n: generated from the locales config, rendered with the page;
- Custom plugin: another set injected into the head, with language codes heavily overlapping the generated ones.
hreflang semantics are declaration, not accumulation: two sets that disagree on even one entry (say, a different URL for a locale) are conflicting annotations as far as search engines are concerned.
The fix: remove the custom injection, rely on built-in i18n
The fix is subtraction — delete the custom plugin's hreflang injection entirely and keep only what Docusaurus generates:
// custom SEO plugin
- const links = locales.map((locale) => ({
- rel: 'alternate',
- hreflang: locale,
- href: urlFor(locale),
- }));
- // inject into head ...
Post-fix live measurement (homepages of both sites): en-US, zh-CN and x-default each appear exactly once — one clean set. Re-run the grep above after the change; ship only when every locale code counts exactly one.
Boundary cases
- When manual injection is actually needed: only for pages Docusaurus doesn't generate, or variant URLs that don't follow i18n routing rules. Even then, grep the current state first — the built-in generator may already cover the pair you're about to declare.
- Duplicates don't only come from your own plugin: multiple SEO plugins stacking, or theme plus plugin each contributing a set, are common sources; debug by disabling one injector at a time — every head tag should have exactly one owner.
- hreflang vs canonical: canonical handles "duplicate content in the same language", hreflang handles "cross-language variants"; neither replaces the other, and custom logic shouldn't entangle the two.
Watch out
- Built-in capability before custom code: Docusaurus ships i18n, sitemap and meta-description infrastructure out of the box — confirm what's already covered before writing injectors, or you're manufacturing duplicates.
- Make the grep self-check part of the release flow: one line of cost that catches an entire class of head-tag conflicts.
- Search engines ignore conflicting hreflang silently — no error, no warning. It's the same "silent, no alarm" family as dotenv truncating values.
FAQ
What are hreflang tags in SEO?
They tell search engines which language/region variants a page has and which URL each maps to, powering regional targeting for multilingual sites. Format: <link rel="alternate" hreflang="zh-CN" href="...">.
How do I check hreflang tags on a page?
Fetch the page source and count: curl -s https://your-site.com/ | grep -o 'hreflang="[^"]*"' | sort | uniq -c. Each locale code (including x-default) should appear exactly once; more means a duplicate injection somewhere.
Does Docusaurus generate hreflang automatically?
Yes — with i18n.locales configured, Docusaurus emits the full alternate set plus x-default for every page out of the box. No plugin needed; adding one is how duplicates happen.
CCLEE
Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.
Work with me