<?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: Sergei Ivantsov</title>
    <description>The latest articles on DEV Community by Sergei Ivantsov (@sergei-swh).</description>
    <link>https://dev.to/sergei-swh</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%2F4119596%2Fc72813a8-b266-489d-8ce0-d9847b1e81d5.jpg</url>
      <title>DEV Community: Sergei Ivantsov</title>
      <link>https://dev.to/sergei-swh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sergei-swh"/>
    <language>en</language>
    <item>
      <title>Four layers of defense for a multilingual site</title>
      <dc:creator>Sergei Ivantsov</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:25:25 +0000</pubDate>
      <link>https://dev.to/sergei-swh/four-layers-of-defense-for-a-multilingual-site-26b8</link>
      <guid>https://dev.to/sergei-swh/four-layers-of-defense-for-a-multilingual-site-26b8</guid>
      <description>&lt;p&gt;I keep building multilingual sites. Restaurants, shops, now apartment listings on Northern Cyprus. People assume the hard part is translation. It isn't. The hard part is keeping four languages in sync after you ship, when nobody is looking.&lt;/p&gt;

&lt;p&gt;Here is what actually holds a multilingual site together. Not one trick. Four layers, and each one catches a different kind of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the generator
&lt;/h2&gt;

&lt;p&gt;Text used to live copy-pasted across twenty HTML files: five page types, four languages. On September 7 that broke. The title, the &lt;code&gt;og:title&lt;/code&gt;, and the JSON-LD &lt;code&gt;name&lt;/code&gt; for one listing were out of sync on all four languages, because someone (me, with an AI agent) copied the string by hand one too many times. The Polish version used a different phrase entirely.&lt;/p&gt;

&lt;p&gt;The fix wasn't "be more careful next time." It was &lt;code&gt;build/generate-seo.js&lt;/code&gt;: one data file, one generator, twenty files written from it. A &lt;code&gt;--check&lt;/code&gt; flag fails the build if a file drifted from the source instead of silently overwriting it. This is the layer that makes structural drift physically impossible, not just unlikely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node build/generate-seo.js &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;span class="go"&gt;SEO head blocks are up to date.
Body blocks are up to date.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole contract: the generator either confirms the twenty files still match the source, or it fails the build. No middle state where a file is quietly out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the linter
&lt;/h2&gt;

&lt;p&gt;The generator can't see style. It writes what the data file says, correctly, forever. If the data file itself is inconsistent, the generator faithfully ships that inconsistency four times.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;code&gt;build/lint-text.js&lt;/code&gt;. It caught British spelling mixed with American in my own English copy. It caught the same empty hedge word duplicated across languages by the same translation habit: "actually" in English, "на самом деле" in Russian, "gerçekte" in Turkish, "naprawdę" in Polish — one language picks up a verbal tic, and if you don't check, it rides along into every translation.&lt;/p&gt;

&lt;p&gt;Dash frequency needed per-language thresholds, not one global number. English prose runs light on em dashes. Russian carries more. Turkish and Polish sit somewhere in between. A linter tuned for one language would either flag good Russian prose as robotic or miss actual robotic English.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node build/lint-text.js
&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;· listing reciaoglu    en  empty evaluative adjective   2
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Notes: 0 warnings, 1 flag.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(trimmed; the real run checks every listing in all four languages and prints a full table even when it finds nothing — that flag above was a real one, not staged for this article.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the trained eye
&lt;/h2&gt;

&lt;p&gt;Neither tool catches meaning. A flyer said "~50 km to both airports." True for Ercan. Off by 20 km for Larnaca. Grammatically fine, technically consistent, and wrong, because nobody checked the number against the actual reader driving to the actual airport. That's not a generator problem or a linter problem. That's a person who reads the sentence and asks "is this true for the person landing at Larnaca," and my background is translation, so noticing when a sentence says one thing in one language and something slightly different in another is close to the whole job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-           &amp;lt;li&amp;gt;About 50 km to Ercan and Larnaca airports&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+           &amp;lt;li&amp;gt;About 50 minutes by car to Ercan airport&amp;lt;/li&amp;gt;
+           &amp;lt;li&amp;gt;About 70 minutes to Larnaca — the route crosses the checkpoint&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One commit, same change repeated across all four language blocks in the same file — because the flawed number wasn't a typo in one place, it was the same inherited assumption translated four times.&lt;/p&gt;

&lt;p&gt;The same instinct works before anything is written, not just after. Nobody asked for a disclaimer page or an "about" page on a private listing site. I added both on day two: the site is not an agency, takes no prepayment, and the deal is between the two sides. No checklist told me to. It's the same trained eye, pointed at trust instead of at a sentence — noticing what a reader needs to see before they'll believe the rest of the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: going back and looking
&lt;/h2&gt;

&lt;p&gt;The three layers above run at build time. None of them catch a section that's simply missing from the template. &lt;code&gt;data-reading&lt;/code&gt;, the "worth reading before you buy" block linking to the climate article, was absent from three of four homepage templates entirely. Not empty. Not there. The generator can't fill a slot that doesn't exist, and no linter checks for missing HTML structure.&lt;/p&gt;

&lt;p&gt;I found it by doing the unglamorous thing: opening the live site periodically and reading it like a stranger would. Same pass caught the language button order drifting out of sync with the default language, and a footer note still describing two listings after two more had been added.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why all four, and not just the best one
&lt;/h2&gt;

&lt;p&gt;Each layer catches what the others structurally cannot. The generator kills copy-paste drift. The linter kills style drift the generator inherits from bad source data. The eye kills meaning-level errors no automated check understands. And periodic re-reading kills the class of bug where something simply isn't there to check.&lt;/p&gt;

&lt;p&gt;Skip any one layer and a specific kind of mistake gets through, quietly, in all four languages at once.&lt;/p&gt;

&lt;p&gt;This whole habit started with the same thought as the first post in this series: "let's just do it right." Turns out doing it right on a multilingual site means doing it right four times, once per language, every single time.&lt;/p&gt;

&lt;p&gt;The site is still here and growing: &lt;a href="https://girne-apartment-sale.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=article07" rel="noopener noreferrer"&gt;girne-apartment-sale.com&lt;/a&gt;. The layer 1 generator is open source: &lt;a href="https://github.com/sergei-swh/seo-generator" rel="noopener noreferrer"&gt;seo-generator on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>i18n</category>
      <category>seo</category>
    </item>
    <item>
      <title>My AI agent copy-pasted SEO tags across 20 files instead of generating them</title>
      <dc:creator>Sergei Ivantsov</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:01:55 +0000</pubDate>
      <link>https://dev.to/sergei-swh/my-ai-agent-copy-pasted-seo-tags-across-20-files-instead-of-generating-them-4lg2</link>
      <guid>https://dev.to/sergei-swh/my-ai-agent-copy-pasted-seo-tags-across-20-files-instead-of-generating-them-4lg2</guid>
      <description>&lt;p&gt;I wrote a small generator to stop an AI coding agent from hand-duplicating SEO metadata across a multilingual static site. It has a &lt;code&gt;--check&lt;/code&gt; flag that verifies the generated files still match their data source. The day after I shipped it, I found that when the generator silently corrupted all 20 pages, &lt;code&gt;--check&lt;/code&gt; reported it as "out of date" — the exact same message it prints when you legitimately edit the data file and haven't regenerated yet.&lt;/p&gt;

&lt;p&gt;That's the part worth writing down. A verification flag that can't distinguish "you changed something on purpose" from "the tool just wrote broken URLs into every page you have" isn't verifying much. Here's how I got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The site
&lt;/h2&gt;

&lt;p&gt;A free, direct-from-owner property listing site for North Cyprus — &lt;a href="https://girne-apartment-sale.com" rel="noopener noreferrer"&gt;girne-apartment-sale.com&lt;/a&gt;, no agency, no commission. Vanilla HTML/CSS/JS, zero npm dependencies, deployed to Cloudflare Pages. Four languages (EN/RU/TR/PL), each language a real indexable URL rather than a client-side toggle, so 5 page types × 4 languages = 20 HTML files. Written together with Claude Code.&lt;/p&gt;

&lt;p&gt;I have a professional translation background, so my bar for "done" here was a genuinely correct multilingual site, not a page that renders. That bar is the only reason the rest of this happened — nothing below was surfaced by the agent on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first bug: no single source of truth
&lt;/h2&gt;

&lt;p&gt;The agent put &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;og:title&lt;/code&gt;, and the JSON-LD &lt;code&gt;name&lt;/code&gt; into each of those 20 files by hand, as literal strings, rather than deriving them from one place. That works right up until you update 19 files and miss one.&lt;/p&gt;

&lt;p&gt;I caught it by re-running the site against an old GEO/AEO audit checklist I had lying around, written for a completely unrelated niche (health, not real estate) and used purely as a generic methodology pass — hreflang, canonicals, structured data. The second listing's Polish version had different wording in its title and description than the other three languages. Not a typo: drift. Individually harmless, collectively the exact failure a site that advertises itself as multilingual cannot afford.&lt;/p&gt;

&lt;p&gt;Worth noting what &lt;em&gt;didn't&lt;/em&gt; happen: reference material of mine describing the practices I wanted — zero dependencies, one source of truth for data, structured data over keyword stuffing — was sitting in the working environment the whole time. None of it got applied unprompted. Every item had to be asked for explicitly, one at a time. Being available in context is not the same as being pulled forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why it isn't Hugo
&lt;/h2&gt;

&lt;p&gt;The obvious objection first: this is a solved problem, and Eleventy, Hugo, Jinja, or any templating layer solves it out of the box. True. I didn't use one, deliberately.&lt;/p&gt;

&lt;p&gt;Adopting an SSG here means moving the entire page body — layout, listing copy, gallery markup, four languages of it — into a template system, to fix a problem that lives in roughly 15 lines of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. The migration is the whole site; the bug is the header. So instead: &lt;code&gt;build/seo-data.js&lt;/code&gt; holds title, meta description, Open Graph, hreflang, and JSON-LD for every page in one place, and &lt;code&gt;build/generate-seo.js&lt;/code&gt; injects it into each file between &lt;code&gt;&amp;lt;!-- SEO:START --&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;!-- SEO:END --&amp;gt;&lt;/code&gt; markers. Everything outside those markers is never touched — the HTML stays hand-written HTML, editable without knowing the generator exists. Plain Node, no dependencies. &lt;code&gt;--check&lt;/code&gt; verifies without writing, run before deploy.&lt;/p&gt;

&lt;p&gt;That closed the drift class structurally. There's no second copy of the string left anywhere for me or the agent to forget.&lt;/p&gt;

&lt;p&gt;Standalone, genericized version with an example data file, if you want the code rather than the description: &lt;a href="https://github.com/sergei-swh/seo-generator" rel="noopener noreferrer"&gt;https://github.com/sergei-swh/seo-generator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: the tool built to stop looking
&lt;/h2&gt;

&lt;p&gt;Then I tested adding a fifth language — German — to see what the generator did with it.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;"de"&lt;/code&gt; to the language list without the matching &lt;code&gt;paths&lt;/code&gt; / &lt;code&gt;perLang&lt;/code&gt; / &lt;code&gt;homeLabel&lt;/code&gt; data didn't error. The hreflang builder loops over every configured language for every page, found no path for German, and wrote &lt;code&gt;href="https://girne-apartment-sale.comundefined"&lt;/code&gt; into all 20 existing pages. Pages that were correct a minute earlier. And &lt;code&gt;--check&lt;/code&gt; dutifully reported: out of date. Same string as any ordinary pending edit.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;validateData()&lt;/code&gt; pass that runs before anything is written and fails loudly with an explicit list of what's missing. But the lesson generalizes past this one script: if your verification step reports "different from expected" without a notion of &lt;em&gt;how&lt;/em&gt; different, it is a diff, not a check. Mine was a diff pretending to be a check from the moment I wrote it, and I only found out because I went poking at a tool I had specifically built so I could stop poking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;I didn't run this at max capability — Sonnet 5, reasoning effort medium, not higher — so none of this is a claim about what these agents do at their best, or in general. It's what happened in this setup, starting from an existing foundation rather than a cold start.&lt;/p&gt;

&lt;p&gt;What I'd take from it: an AI agent will reliably produce something that works, and it optimizes hardest for the layer you can see. The failure modes live one layer down, in duplication it introduces because duplication is locally cheaper than structure, and they surface as drift rather than as errors. Nothing about that is exotic — it's the same failure mode as any copy-paste — but it arrives faster and across more files than a human would manage, and it doesn't get flagged, because from the inside every individual file looks fine.&lt;/p&gt;

&lt;p&gt;The site is still here and growing: &lt;a href="https://girne-apartment-sale.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=article02" rel="noopener noreferrer"&gt;girne-apartment-sale.com&lt;/a&gt;. The generator from this post is open source: &lt;a href="https://github.com/sergei-swh/seo-generator" rel="noopener noreferrer"&gt;seo-generator on GitHub&lt;/a&gt;.&lt;/p&gt;

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