DEV Community

Ohad Farkash
Ohad Farkash

Posted on

The multilingual bugs that never throw: hreflang, JSON-LD and a site in 12 languages

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/">
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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 */ }
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Apply the replacement to an in-memory copy.
  2. Extract every <script type="application/ld+json"> block and JSON.parse each one.
  3. If any block fails to parse, skip the file entirely and log it.
  4. 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 (1)

Collapse
 
alexshev profile image
Alex Shev

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.