Nine Languages on a Static Site: Four hreflang Mistakes I Made
Last year I helped put a small B2B site into nine languages. No CMS, no i18n framework — just static HTML, one directory per locale, deployed to a CDN. The build was simple. The internationalization was not. Six months
Last year I helped put a small B2B site into nine languages. No CMS, no i18n framework — just static HTML, one directory per locale, deployed to a CDN. The build was simple. The internationalization was not.
Six months in, the numbers told me something was wrong: one locale was indexed at roughly 70% of its pages while the English source sat near 50%, and a third of the "alternate page" reports pointed at URLs that looked correct on the surface. Here are the four mistakes, in the order I made them.
Mistake 1: treating hreflang as a sitemap annotation
I started by generating hreflang only in the sitemap. It is valid — Google accepts hreflang from sitemaps, HTTP headers, or on-page <link> tags. What I missed is that the three sources are not interchangeable in practice.
If you put hreflang only in a sitemap, nothing on the page itself tells a crawler that this page has siblings. If a page is discovered through a link rather than the sitemap, the relationship is invisible until the sitemap is re-read. And when a page moves or a locale is retired, the sitemap annotation is the easiest thing to forget, because it lives in a different file from the page.
What fixed it: on-page <link rel="alternate" hreflang> on every page, all locales plus x-default, generated from one source of truth at build time. The sitemap annotation stayed as a backup, not the primary signal.
<link rel="alternate" hreflang="en" href="https://example.com/page.html">
<link rel="alternate" hreflang="de" href="https://example.com/de-page.html">
<link rel="alternate" hreflang="x-default" href="https://example.com/page.html">
Two details that matter more than they look:
- hreflang must be reciprocal. If page A lists B, page B must list A. A one-way pair is ignored.
-
x-defaultshould point at the language selector or the primary locale, not at a locale-specific page you happen to prefer.
Mistake 2: letting locale pages drift into near-duplicates
This is the one that actually cost the most. When you translate a page, you often translate the chrome faithfully and the body loosely — or worse, you translate the body but leave the same product spec tables, the same image alt text patterns, and the same boilerplate blocks. The result is nine pages that are 85% identical at the markup level.
Search engines do not see "nine languages." They see nine URLs with highly similar structure and decide some of them are alternates of each other. That is exactly what hreflang is supposed to tell them — but when the similarity is high enough, the consolidation can happen in ways you did not intend, and pages start showing up as "alternate page with proper canonical tag" in volume.
What fixed it, partially: measuring it. I wrote a script that strips HTML tags, keeps only visible text, and compares each locale page against the English source using a simple character n-gram similarity. Anything above ~80% got flagged. The flagged pages were almost always ones where the body had been machine-translated without adaptation — correct sentences, but the same sentences.
The real fix is editorial, not technical: locale pages need locale-specific content. Local units, local standards references, local lead times, local FAQ phrasing. If a page cannot justify being different, consider whether it should exist at all.
Mistake 3: canonical and hreflang pointing at different things
For a while I had pages where the canonical tag pointed at the English source and hreflang pointed at the page itself. Each tag was defensible alone. Together they were contradictory: canonical says "I am a duplicate, index that one instead," hreflang says "I am the German version of this cluster."
The rule I settled on: each locale page self-canonicals, and hreflang does the clustering. If a page is genuinely a duplicate of another page in the same language, canonical it. If it is a translation, self-canonical and let hreflang do the work.
Mistake 4: one sitemap for everything
A single sitemap containing every URL in every locale is fine at small scale and painful past a few hundred URLs. It also makes lastmod meaningless — you touch one German page, the whole sitemap's mtime changes, and any downstream consumer that trusts mtime thinks everything changed.
What fixed it: split by language family, then by page type where needed.
/sitemap.xml → index
/sitemap-en.xml → English pages only
/sitemap-mirror.xml → all non-English locales
Two rules I now follow:
-
lastmodmust reflect actual content change, not file write time. If you cannot guarantee that, omit it rather than lie. - Never let a build step rewrite all sitemap files on every deploy. It destroys the one signal you have.
What I would do differently
If I started over, the order would be:
- Decide per page whether a locale version earns its existence. Not every page needs nine languages. A page that only differs by translated boilerplate is a liability.
- Generate hreflang, canonical, and sitemap entries from one data structure, at build time, with an assertion that reciprocity holds.
- Measure similarity before publishing, not after search engines have formed an opinion.
-
Keep
lastmodhonest, even if it means extra bookkeeping.
The site I learned this on is mujiatextile.com — a nine-locale static build for a textile manufacturer, small enough to hold in your head and stubborn enough to expose every one of these problems.
None of these are exotic. They are the standard i18n SEO checklist. What surprised me is how much damage each one does quietly — no errors, no broken pages, just a slow accumulation of pages that search engines have decided not to treat as distinct.
If you are building a multi-locale static site, the cheapest thing you can do is write the similarity script early. It will tell you which of your nine languages are actually nine.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.