I run a search engine that publishes in twelve languages from one static site on Cloudflare Pages. Last week I audited its machine-readable layer — the part crawlers and answer engines read rather than humans — and found four problems.
None of them threw an error. None appeared in logs. Every page rendered perfectly. That is the whole point of this post: the multilingual layer fails in a register where nothing tells you.
1. The homepage was serving the wrong language to everyone abroad
The site's primary market speaks Hebrew, so / is Hebrew and /en/, /ar/, /de/ and nine others sit alongside it.
A middleware rule redirected visitors from one specific region to their language. Everyone else — including every English speaker on earth — landed on Hebrew.
My first instinct was to fix it with a broader geo-redirect: detect English-speaking countries, send them to /en/. This would have been a bad idea, and it is worth saying why.
Googlebot crawls predominantly from US IPs. A geo-redirect on / that keys off country would take the crawler off the Hebrew homepage and onto the English one almost every time it visited. You do not want your primary-market homepage to become the page the crawler can never reach.
The correct tool is hreflang, and it is what search engines built for exactly this. Checking the page, the tags were already there and already right:
<link rel="alternate" hreflang="he" href="https://example.com/">
<link rel="alternate" hreflang="en" href="https://example.com/en/">
<link rel="alternate" hreflang="ar" href="https://example.com/ar/">
<!-- …ten more… -->
<link rel="alternate" hreflang="x-default" href="https://example.com/en/">
Two things make this work, and both are easy to get wrong:
The set must be reciprocal. Every page in the group lists every other page including itself. If /en/ does not point back at /, search engines are entitled to ignore the whole cluster.
x-default is not "the default language" — it is the fallback for users you have no better match for. Pointing it at the Hebrew homepage would have been the intuitive reading and the wrong one. It belongs on whichever version serves someone whose language you do not publish, which for most sites is English.
With that in place, an English searcher gets /en/ from the search engine directly, and the crawler still sees the Hebrew homepage as the Hebrew homepage. No redirect needed.
The residual gap is worth naming honestly: hreflang is a search-engine protocol. A crawler that simply fetches your bare domain and reads what comes back — which is what several AI crawlers do — still gets your primary language. There is no clean fix for that from inside hreflang. What I did instead was make sure the English URL is the one used everywhere off-site, in every directory listing and profile.
2. Structured data claimed eight languages; the site had twelve
The WebApplication node carried:
"inLanguage": ["he","en","ar","ru","es","pt","tr","fr"]
Four languages had been added since that array was written. Nobody updates a hand-maintained list in a JSON-LD blob, because nothing breaks when it goes stale. It just quietly asserts something untrue about your site, in the most machine-readable place on the page.
If a value in your structured data duplicates a fact that lives elsewhere in your codebase — supported languages, prices, feature lists — either generate it from the source of truth or add an assertion. A test that reads the language directory listing and compares it to the array is about ten lines.
3. sameAs is the entity-linking mechanism and mine was two years behind
Organization.sameAs is how you tell a search engine "these profiles are the same entity as this site." Mine listed two profiles. Two more had been created and verified since, and neither was in the list.
This is the same failure as the language array, with higher stakes: the whole value of building profiles elsewhere is that the site claims them. Unclaimed profiles are just pages that happen to mention you.
One judgement worth stating: I deliberately left out a directory listing that had been submitted but was still in a moderation queue, because its URL 404s until approval. A sameAs pointing at a 404 is worse than an absent one — you are asserting an identity link to a page that does not exist.
4. The bug that nearly made me report the fix as a failure
I updated sameAs across the site with a scripted replacement, then wrote a verification pass to count how many nodes had changed.
It reported 21. The replacement had touched 365.
My verifier iterated over the top level of each ld+json block:
const items = Array.isArray(parsed) ? parsed : [parsed];
for (const item of items) { /* check item.sameAs */ }
Most Organization nodes are not at the top level. They are nested inside publisher, or author, or mainEntity. A flat scan sees a small fraction of them.
function* walk(node) {
if (Array.isArray(node)) { for (const v of node) yield* walk(v); }
else if (node && typeof node === "object") {
yield node;
for (const v of Object.values(node)) yield* walk(v);
}
}
I was one step away from telling my client the bulk edit had barely applied. When you verify a change to nested data, walk the tree. A verifier that is structurally simpler than the data it checks will lie to you, and it will lie in the confident direction — a number, not an error.
The check I now run before any bulk edit to structured data
Bulk-editing HTML with string replacement is exactly as dangerous as it sounds, and JSON-LD has a nasty property: a broken block does not break the page. The browser ignores it, the layout is fine, and the damage is invisible until someone runs a validator months later.
So the script does this, per file, before writing anything:
- Apply the replacement to an in-memory copy.
- Extract every
<script type="application/ld+json">block andJSON.parseeach one. - If any block fails to parse, skip the file entirely and log it.
- Only then write, keeping a backup.
Then a separate pass re-parses every block on the whole site — 1,753 of them — and reports the count of unparseable blocks. That number has to be zero.
None of this is clever. It is just the acknowledgement that in the machine-readable layer, "it still looks fine" is not evidence of anything.
Top comments (5)
This is a useful reminder that multilingual SEO failures are often data-consistency failures, not rendering failures. The page can look perfect while the machine-readable layer quietly describes a different site.
One practical check I like is to treat each locale as a graph: every page should list the same complete set of reciprocal hreflang URLs, its canonical should stay self-consistent, and the
x-defaulttarget should be intentional. Then compare that set with the locales declared in JSON-LD and the URLs present in the sitemap. Running this across a small locale matrix in CI can catch stale language lists and one-way hreflang links before Search Console discovers them.The warning about geo-redirects is especially important. Content negotiation can be useful for humans, but it should not become the only way a crawler can reach a language version.
The graph framing is the right one, and I would extend it past
hreflang to plain internal links. I checked mine this week: the Hebrew
homepage linked all four of its tool pages, and the Arabic homepage
linked zero of its eight. Same template, same build, no error anywhere
— the pages were simply unreachable from their own locale's entry
point.
hreflang was correct the whole time. A locale can be a perfectly
consistent graph and still have no edges into half its own nodes.
That’s a great point. A correct hreflang setup isn’t enough if the internal-link graph leaves pages unreachable. I’ll add locale-by-locale internal-link checks to my SEO checklist.
This is the right implementation lens. For a local-service site, I would make the underlying fact explicit in the content model, render it in the initial HTML, and keep the same value in JSON-LD rather than maintaining a second SEO-only field.
Agreed, and the second-field version is exactly what bit me. My
WebApplication.inLanguage was a hand-maintained copy that claimed 8
languages while hreflang, the nav and the content all said 12. Nothing
threw, nothing looked wrong, and it sat there for months.
What made it survivable was that the value existed twice. A single
source rendered into both surfaces makes that class of bug
unrepresentable rather than merely unlikely.