<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ohad Farkash</title>
    <description>The latest articles on DEV Community by Ohad Farkash (@ohadfarkash).</description>
    <link>https://dev.to/ohadfarkash</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089718%2F8da40890-910d-4279-966d-5b2a14b7254c.png</url>
      <title>DEV Community: Ohad Farkash</title>
      <link>https://dev.to/ohadfarkash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohadfarkash"/>
    <language>en</language>
    <item>
      <title>The multilingual bugs that never throw: hreflang, JSON-LD and a site in 12 languages</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:47:29 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/the-multilingual-bugs-that-never-throw-hreflang-json-ld-and-a-site-in-12-languages-50d2</link>
      <guid>https://dev.to/ohadfarkash/the-multilingual-bugs-that-never-throw-hreflang-json-ld-and-a-site-in-12-languages-50d2</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The homepage was serving the wrong language to everyone abroad
&lt;/h2&gt;

&lt;p&gt;The site's primary market speaks Hebrew, so &lt;code&gt;/&lt;/code&gt; is Hebrew and &lt;code&gt;/en/&lt;/code&gt;, &lt;code&gt;/ar/&lt;/code&gt;, &lt;code&gt;/de/&lt;/code&gt; and nine others sit alongside it.&lt;/p&gt;

&lt;p&gt;A middleware rule redirected visitors from one specific region to their language. Everyone else — including every English speaker on earth — landed on Hebrew.&lt;/p&gt;

&lt;p&gt;My first instinct was to fix it with a broader geo-redirect: detect English-speaking countries, send them to &lt;code&gt;/en/&lt;/code&gt;. &lt;strong&gt;This would have been a bad idea, and it is worth saying why.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Googlebot crawls predominantly from US IPs. A geo-redirect on &lt;code&gt;/&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The correct tool is &lt;code&gt;hreflang&lt;/code&gt;, and it is what search engines built for exactly this. Checking the page, the tags were already there and already right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"he"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/en/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"ar"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/ar/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- …ten more… --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/en/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this work, and both are easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The set must be reciprocal.&lt;/strong&gt; Every page in the group lists every other page &lt;em&gt;including itself&lt;/em&gt;. If &lt;code&gt;/en/&lt;/code&gt; does not point back at &lt;code&gt;/&lt;/code&gt;, search engines are entitled to ignore the whole cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;x-default&lt;/code&gt; is not "the default language" — it is the fallback for users you have no better match for.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;With that in place, an English searcher gets &lt;code&gt;/en/&lt;/code&gt; from the search engine directly, and the crawler still sees the Hebrew homepage as the Hebrew homepage. No redirect needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The residual gap is worth naming honestly:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Structured data claimed eight languages; the site had twelve
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;WebApplication&lt;/code&gt; node carried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"inLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"ar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"ru"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"es"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"pt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"tr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;sameAs&lt;/code&gt; is the entity-linking mechanism and mine was two years behind
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Organization.sameAs&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This is the same failure as the language array, with higher stakes: the whole value of building profiles elsewhere is that the site &lt;em&gt;claims&lt;/em&gt; them. Unclaimed profiles are just pages that happen to mention you.&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;A &lt;code&gt;sameAs&lt;/code&gt; pointing at a 404 is worse than an absent one&lt;/strong&gt; — you are asserting an identity link to a page that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The bug that nearly made me report the fix as a failure
&lt;/h2&gt;

&lt;p&gt;I updated &lt;code&gt;sameAs&lt;/code&gt; across the site with a scripted replacement, then wrote a verification pass to count how many nodes had changed.&lt;/p&gt;

&lt;p&gt;It reported &lt;strong&gt;21&lt;/strong&gt;. The replacement had touched &lt;strong&gt;365&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My verifier iterated over the top level of each &lt;code&gt;ld+json&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* check item.sameAs */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most &lt;code&gt;Organization&lt;/code&gt; nodes are not at the top level. They are nested inside &lt;code&gt;publisher&lt;/code&gt;, or &lt;code&gt;author&lt;/code&gt;, or &lt;code&gt;mainEntity&lt;/code&gt;. A flat scan sees a small fraction of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was one step away from telling my client the bulk edit had barely applied. &lt;strong&gt;When you verify a change to nested data, walk the tree.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I now run before any bulk edit to structured data
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the script does this, per file, before writing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apply the replacement to an in-memory copy.&lt;/li&gt;
&lt;li&gt;Extract every &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block and &lt;code&gt;JSON.parse&lt;/code&gt;
each one.&lt;/li&gt;
&lt;li&gt;If any block fails to parse, &lt;strong&gt;skip the file entirely&lt;/strong&gt; and log it.&lt;/li&gt;
&lt;li&gt;Only then write, keeping a backup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>javascript</category>
      <category>webperf</category>
    </item>
  </channel>
</rss>
