<?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: Franco Ortiz</title>
    <description>The latest articles on DEV Community by Franco Ortiz (@pakvothe).</description>
    <link>https://dev.to/pakvothe</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%2F3851680%2Fad99bd9b-108e-43a4-90d8-dc9a2c8b2bc4.jpg</url>
      <title>DEV Community: Franco Ortiz</title>
      <link>https://dev.to/pakvothe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pakvothe"/>
    <language>en</language>
    <item>
      <title>Why AI-translating your app quietly breaks placeholders (and how to validate it)</title>
      <dc:creator>Franco Ortiz</dc:creator>
      <pubDate>Wed, 22 Jul 2026 17:07:49 +0000</pubDate>
      <link>https://dev.to/pakvothe/why-ai-translating-your-app-quietly-breaks-placeholders-and-how-to-validate-it-23pg</link>
      <guid>https://dev.to/pakvothe/why-ai-translating-your-app-quietly-breaks-placeholders-and-how-to-validate-it-23pg</guid>
      <description>&lt;p&gt;The first time I AI-translated a locale file, it looked perfect. Every string had a Spanish version, the tone was right, nothing was missing. I shipped it. Two days later a user reported the app was printing &lt;code&gt;Hola {nombre}&lt;/code&gt; on screen, literally, braces and all. The model had helpfully translated the variable name &lt;code&gt;{name}&lt;/code&gt; into &lt;code&gt;{nombre}&lt;/code&gt;, and now the interpolation didn't match any variable my code was passing.&lt;/p&gt;

&lt;p&gt;That's the thing nobody tells you about using an LLM for translation. It doesn't just translate the words you want translated. It translates &lt;em&gt;everything that looks like words&lt;/em&gt;, and a surprising amount of what's inside a locale string isn't text meant for humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually breaks
&lt;/h2&gt;

&lt;p&gt;Locale strings are full of machine parts that have to survive translation byte-for-byte. An LLM sees them as language and rewrites them. A few real failure modes I've hit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable names get localized.&lt;/strong&gt; &lt;code&gt;Welcome, {name}&lt;/code&gt; becomes &lt;code&gt;Bienvenido, {nombre}&lt;/code&gt;. The braces are intact, the sentence reads fine, and the string is broken because your code injects &lt;code&gt;name&lt;/code&gt;, not &lt;code&gt;nombre&lt;/code&gt;. This one is nasty because it looks completely correct in review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plural syntax gets mangled.&lt;/strong&gt; ICU messages like &lt;code&gt;{count, plural, one {# item} other {# items}}&lt;/code&gt; are a mini-language. The model translates &lt;code&gt;item&lt;/code&gt; and &lt;code&gt;items&lt;/code&gt; (good) but also sometimes rewrites &lt;code&gt;one&lt;/code&gt;/&lt;code&gt;other&lt;/code&gt; into the target language or drops the &lt;code&gt;#&lt;/code&gt;, and now the whole message fails to parse at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline markup gets dropped or translated.&lt;/strong&gt; A string like &lt;code&gt;Read our &amp;lt;link&amp;gt;privacy policy&amp;lt;/link&amp;gt;&lt;/code&gt; comes back with &lt;code&gt;&amp;lt;enlace&amp;gt;&lt;/code&gt; tags, or with the tags silently removed, or reordered so your React component can't match them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Printf-style tokens vanish.&lt;/strong&gt; &lt;code&gt;%s uploaded %d files&lt;/code&gt; is easy for a model to "clean up" into something that reads better and passes zero arguments correctly.&lt;/p&gt;

&lt;p&gt;None of these throw at translation time. They throw in production, in a language you probably don't read, for users who mostly won't file a bug and will just leave. It's the exact silent-degradation problem as &lt;a href="https://i1n.ai/blog/keep-locale-json-files-in-sync/" rel="noopener noreferrer"&gt;locale drift&lt;/a&gt;, except now you introduced it yourself by trying to fix drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1: tell the model what not to touch
&lt;/h2&gt;

&lt;p&gt;The first defense is the prompt. If you're calling an LLM directly, be explicit and give it the variable syntax you actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Translate the values to {targetLang}. Do NOT translate or modify anything
inside {curly braces}, %placeholders, or &amp;lt;tags&amp;gt;. Keep them exactly as-is,
in the same order. Return the same JSON structure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cuts the error rate a lot. It does not get you to zero. Models still slip, especially on longer strings, plural blocks, and anything where reordering the sentence "wants" to move a token. Prompting reduces the odds. It doesn't give you a guarantee, and locale correctness is a place where you want a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2: validate the output, don't trust it
&lt;/h2&gt;

&lt;p&gt;The move that actually works is to stop trusting the model and verify its output mechanically. Extract every placeholder from the source string, extract every placeholder from the translation, and assert the two sets match. Different order is fine. Different &lt;em&gt;set&lt;/em&gt; is a bug.&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="c1"&gt;// pull out {name}, {{count}}, %{item}, %s, %d, &amp;lt;tag&amp;gt;...&amp;lt;/tag&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLACEHOLDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{?[^&lt;/span&gt;&lt;span class="sr"&gt;}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\}?\}&lt;/span&gt;&lt;span class="sr"&gt;|%&lt;/span&gt;&lt;span class="se"&gt;\{[^&lt;/span&gt;&lt;span class="sr"&gt;}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\}&lt;/span&gt;&lt;span class="sr"&gt;|%&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;sd&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/?[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLACEHOLDER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;translated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;translated&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Placeholder mismatch in "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;":\n  source:     &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n  translated: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&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;That catches the &lt;code&gt;{name}&lt;/code&gt; to &lt;code&gt;{nombre}&lt;/code&gt; case, the dropped &lt;code&gt;%s&lt;/code&gt;, and the missing &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;. For ICU plurals you want a real parser (&lt;code&gt;@formatjs/icu-messageformat-parser&lt;/code&gt;) rather than a regex, so you can compare the message &lt;em&gt;structure&lt;/em&gt;, arguments and plural categories, instead of just the tokens. But even the naive version above would have caught the bug that got me.&lt;/p&gt;

&lt;p&gt;Run this over every translated key, fail loudly on mismatch, and the whole class of "the model quietly broke an interpolation" stops reaching production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 3: put validation in the same step as translation
&lt;/h2&gt;

&lt;p&gt;Level 2 works, but if translation and validation live in different places, someone eventually runs the translation without the check. So the real fix is to make them one operation: you can't get translated output that hasn't been validated, because validation is part of producing it.&lt;/p&gt;

&lt;p&gt;This is what I ended up building into &lt;a href="https://i1n.ai" rel="noopener noreferrer"&gt;i1n&lt;/a&gt;. When it AI-translates a locale, placeholder and structure validation isn't a separate lint step you might forget, it runs on the output before anything gets written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Translation happens with the variable syntax and product context baked in, so the model knows &lt;code&gt;{name}&lt;/code&gt; is off-limits and that "Save" is a button, not a rescue.&lt;/li&gt;
&lt;li&gt;Every translated value is checked against its source for placeholder parity and ICU structure. A broken interpolation never makes it into your files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i1n check&lt;/code&gt; runs the same validation in CI, so even a hand-edited translation or a value from an external tool gets caught the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nice part is that validation and translation reinforce each other. The context makes the model wrong less often, and the check means the times it's still wrong don't cost you a production incident. You review a diff of correct-by-construction translations instead of auditing raw model output for landmines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Prompting the model to leave placeholders alone helps, but it's odds, not a guarantee. Don't stop there.&lt;/li&gt;
&lt;li&gt;Extract placeholders from source and translation and assert they match. This is the check that actually holds. Use a real ICU parser for plurals.&lt;/li&gt;
&lt;li&gt;Make validation inseparable from translation so it can't be skipped, whether you wire that up yourself or let i1n do it (&lt;code&gt;npx i1n init&lt;/code&gt;, free tier, no card).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Raw LLM output is a great first draft and a terrible last word. The models are good enough that the words will be fine. It's the parts that aren't words that will page you.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to keep locale JSON files in sync (next-intl, i18next, or anything else)</title>
      <dc:creator>Franco Ortiz</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:40:08 +0000</pubDate>
      <link>https://dev.to/pakvothe/how-to-keep-locale-json-files-in-sync-next-intl-i18next-or-anything-else-17i6</link>
      <guid>https://dev.to/pakvothe/how-to-keep-locale-json-files-in-sync-next-intl-i18next-or-anything-else-17i6</guid>
      <description>&lt;p&gt;Every multi-language project I've worked on has the same bug. Not a bug in the code, a bug in the process: someone adds a string to &lt;code&gt;en.json&lt;/code&gt;, ships the feature, and forgets that &lt;code&gt;es.json&lt;/code&gt;, &lt;code&gt;de.json&lt;/code&gt; and &lt;code&gt;pt.json&lt;/code&gt; exist. Nothing fails. The build is green. Weeks later a user screenshots your app showing English text in the middle of a Spanish UI, and now it's a support ticket instead of a diff.&lt;/p&gt;

&lt;p&gt;This happens with next-intl, i18next, vue-i18n, react-intl, all of them. The libraries solve &lt;em&gt;rendering&lt;/em&gt; translations. None of them solve &lt;em&gt;keeping the files in sync&lt;/em&gt;, because that's not a runtime problem. It's a workflow problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it keeps happening
&lt;/h2&gt;

&lt;p&gt;The source locale is the only file that's ever up to date, because it's the one developers touch while building. Every other locale updates on a different schedule: when a translator gets around to it, when someone notices, or never.&lt;/p&gt;

&lt;p&gt;The failure is silent by design. Most i18n libraries fall back to the key name or to the source language when a translation is missing. That's the right behavior in production (better English than a raw &lt;code&gt;common.hero.title&lt;/code&gt; on screen), but it means the gap never throws. Missing translations don't crash. They just quietly degrade the product for everyone who doesn't read English.&lt;/p&gt;

&lt;p&gt;And it compounds. Two languages, you can eyeball it. Seven languages and 900 keys, and no human can tell you which locales are complete without tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1: diff the keys
&lt;/h2&gt;

&lt;p&gt;The minimum viable fix is a script that compares key sets between your source locale and everything else. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# keys present in en.json but missing from es.json&lt;/span&gt;
&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'paths(scalars) | join(".")'&lt;/span&gt; locales/en.json | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'paths(scalars) | join(".")'&lt;/span&gt; locales/es.json | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it before release and you at least &lt;em&gt;know&lt;/em&gt; what's missing. Plenty of teams live here, and it's genuinely better than nothing.&lt;/p&gt;

&lt;p&gt;The limits show up fast though. It tells you a key is missing but doesn't fill it. It doesn't catch a translation that exists but is stale because the English source changed. It doesn't validate that &lt;code&gt;{name}&lt;/code&gt; survived into the German version. And someone has to remember to run it, which is the same failure mode we started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2: put the check in CI
&lt;/h2&gt;

&lt;p&gt;The single highest-leverage move: make locale drift fail the build. Missing key in &lt;code&gt;pt.json&lt;/code&gt;? Red CI, PR blocked, fixed in the same change that introduced the string, by the person with the most context. The category of bug where "translations catch up eventually" just stops existing, because incomplete locales can't merge.&lt;/p&gt;

&lt;p&gt;You can wire the diff script from level 1 into GitHub Actions in ten minutes. If you do only one thing from this post, do this. I've seen more i18n pain solved by one required CI step than by any library migration.&lt;/p&gt;

&lt;p&gt;The catch: a blocking check is only tolerable if fixing the failure is cheap. If a missing Japanese string means "file a ticket and wait a week", people will disable the check by the second sprint. Which leads to level 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 3: close the loop with translation
&lt;/h2&gt;

&lt;p&gt;The check tells you what's missing. The remaining question is who fills it in, and this is where most workflows stall, because the answer is usually "a spreadsheet, a translation agency, and a two-week round trip".&lt;/p&gt;

&lt;p&gt;This is the part I ended up automating. I built &lt;a href="https://i1n.ai" rel="noopener noreferrer"&gt;i1n&lt;/a&gt; after hitting this exact wall at the fintech where I work (7 languages, locale files in the repo, translations perpetually behind). The workflow it settles into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strings stay in your locale JSON files, in your repo. No migration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i1n push --translate&lt;/code&gt; diffs every locale against the source and AI-translates only the missing or changed keys, with product context attached so "Save" in a form doesn't come back as "Rescue".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i1n check&lt;/code&gt; runs in CI and fails on missing keys, broken placeholders, or coverage drops.&lt;/li&gt;
&lt;li&gt;Native speakers review diffs instead of translating from scratch, which is the difference between a 10-minute review and a week of waiting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CI check has caught things nobody on the team could have: our Hindi locale drifted for a month once and no one noticed, because nobody on the team reads Hindi. The build noticed.&lt;/p&gt;

&lt;p&gt;Whether you use i1n or build the pipeline yourself, the shape is the same: &lt;strong&gt;source locale as the contract, a diff against it as the check, and translation as an automated step instead of a queue.&lt;/strong&gt; Get those three in place and locale drift stops being a recurring bug and becomes a build error you fix before merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Your locale files are already out of sync. Run a key diff today and find out how much.&lt;/li&gt;
&lt;li&gt;Put that diff in CI as a required check. This is the 90% win.&lt;/li&gt;
&lt;li&gt;Automate the fill-in so the check is cheap to satisfy, with AI translation plus human review, or with i1n doing that pipeline for you (&lt;code&gt;npx i1n init&lt;/code&gt;, free tier, no card).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The drift never comes back once merging depends on it.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Our AI translation pipeline shipped __VAR_1__ to production in 7 languages</title>
      <dc:creator>Franco Ortiz</dc:creator>
      <pubDate>Thu, 09 Jul 2026 13:59:05 +0000</pubDate>
      <link>https://dev.to/pakvothe/our-ai-translation-pipeline-shipped-var1-to-production-in-7-languages-5fag</link>
      <guid>https://dev.to/pakvothe/our-ai-translation-pipeline-shipped-var1-to-production-in-7-languages-5fag</guid>
      <description>&lt;p&gt;A user opened our dashboard in Italian and the button said "Rigenera (VAR_1)". Not a variable. The literal text &lt;strong&gt;VAR_1&lt;/strong&gt;, shipped to production, in seven languages, for weeks. Nobody noticed because we don't read Italian, or Japanese, or Hindi.&lt;/p&gt;

&lt;p&gt;I build a localization tool. This bug was in my own product. That's the embarrassing part, and also why I think the postmortem is worth sharing: the failure mode applies to anyone piping strings through an LLM.&lt;/p&gt;

&lt;p&gt;The setup&lt;br&gt;
Our translation pipeline masks interpolation variables before sending text to the model. Regenerate ({wu} credits) becomes Regenerate (&lt;strong&gt;VAR_1&lt;/strong&gt; credits), the model translates around the token, and we swap the real variable back afterwards. Standard practice. The model can't mangle {wu} into {wu_créditos} if it never sees it.&lt;/p&gt;

&lt;p&gt;Except we had two code paths into the translator. The direct one masked variables. The queued one, added later for batch jobs, didn't. And the prompt, shared by both, told the model "copy tokens like VAR_N through unchanged".&lt;/p&gt;

&lt;p&gt;So for queued jobs the model received unmasked text with instructions about tokens that weren't there. Most of the time it translated fine. Sometimes it obeyed the prompt too literally and wrote &lt;strong&gt;VAR_1&lt;/strong&gt; into the translation, guessing where a token might belong. Valid JSON. Correct-looking diff. Straight into production.&lt;/p&gt;

&lt;p&gt;Why nothing caught it&lt;br&gt;
Every safety net we had was looking somewhere else:&lt;/p&gt;

&lt;p&gt;The tests covered the masking function, which worked. The bug was that one path never called it.&lt;/p&gt;

&lt;p&gt;TypeScript was happy. A string is a string.&lt;/p&gt;

&lt;p&gt;The UI rendered fine. &lt;strong&gt;VAR_1&lt;/strong&gt; is just text, nothing crashes.&lt;/p&gt;

&lt;p&gt;And the humans reviewed the English source, which was correct. The broken strings only existed in languages nobody on the team reads. That's the trap with i18n bugs: your ability to notice them is inversely proportional to how many languages you support.&lt;/p&gt;

&lt;p&gt;What actually surfaced it&lt;br&gt;
I wrote a check command for our CLI, mostly for CI: it diffs every locale against the source, validates that placeholders match, flags empty values. First time I ran it against our own projects it reported 47 broken strings across the landing page and the dashboard. I assumed the checker was wrong. It wasn't.&lt;/p&gt;

&lt;p&gt;Fixing the data took an evening. Fixing the pipeline meant masking on both paths and adding a validation step that rejects any translation whose variables don't match the source, requeuing it with an error instead of shipping it quietly.&lt;/p&gt;

&lt;p&gt;The check now runs in CI. A leaked token or a dropped placeholder fails the build the way a type error does, which is the only place this class of bug is catchable, because no human on the team was ever going to read the Hindi strings.&lt;/p&gt;

&lt;p&gt;The takeaways&lt;br&gt;
If you're doing AI translation, in any form:&lt;/p&gt;

&lt;p&gt;Mask variables before the model sees them, on every path. Auditing that "every path" claim is the actual work.&lt;/p&gt;

&lt;p&gt;Never trust the model's output structurally. Validate placeholders against the source after translation, and reject instead of shipping when they differ.&lt;/p&gt;

&lt;p&gt;Treat locale files like compiled artifacts. Nobody proofreads a build output; you put a check in CI instead.&lt;/p&gt;

&lt;p&gt;The check that caught this is part of i1n, the tool I work on (i1n.ai, CLI is open source). But honestly, even if you never touch it, write the placeholder diff yourself. It's an afternoon of work and it will eventually catch something a human can't, because it reads every language and your team doesn't.&lt;/p&gt;

&lt;p&gt;Has anyone else had an LLM follow instructions too well? The model doing exactly what the prompt said, on input the prompt never anticipated, feels like a whole new bug category.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>ai</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I mass-translated 200 keys into 5 languages. It went wrong in ways I didn't expect.</title>
      <dc:creator>Franco Ortiz</dc:creator>
      <pubDate>Tue, 07 Apr 2026 19:36:29 +0000</pubDate>
      <link>https://dev.to/pakvothe/i-mass-translated-200-keys-into-5-languages-it-went-wrong-in-ways-i-didnt-expect-1nfe</link>
      <guid>https://dev.to/pakvothe/i-mass-translated-200-keys-into-5-languages-it-went-wrong-in-ways-i-didnt-expect-1nfe</guid>
      <description>&lt;p&gt;Last project I had to internationalize an app with around 200 keys across 5 languages. I figured it would take a morning. It took most of a day, and the interesting part wasn't the translating. It was everything else.&lt;/p&gt;

&lt;p&gt;The actual translations were fine. DeepL, Google Translate, whatever. Ten minutes. What killed me was the workflow around it:&lt;/p&gt;

&lt;p&gt;Creating 5 copies of every JSON file. Keeping them in sync. Discovering that &lt;code&gt;{name}&lt;/code&gt; got translated to &lt;code&gt;{nombre}&lt;/code&gt; in the Spanish file and broke interpolation. Finding a typo (&lt;code&gt;settigns.title&lt;/code&gt;) that rendered as blank in production because there's no runtime error for a missing key. Realizing a week later that I forgot three keys in Japanese entirely.&lt;/p&gt;

&lt;p&gt;The thing that surprised me was the ratio. The translation itself was maybe 10% of the time. The other 90% was file management, variable protection, and chasing silent failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What silent failures actually look like
&lt;/h2&gt;

&lt;p&gt;This is the part nobody talks about. Your app doesn't crash when a translation key is wrong. It just renders nothing. Or it renders &lt;code&gt;common.header.subtitl&lt;/code&gt; as literal text. Or it works in English and Spanish but breaks in Japanese because you have &lt;code&gt;{count} items&lt;/code&gt; and the variable got mangled.&lt;/p&gt;

&lt;p&gt;You don't catch these in development because you're developing in English. They show up in production, reported by users who don't speak English, often weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I stopped doing it manually
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/Pakvothe/i1n-cli" rel="noopener noreferrer"&gt;i1n&lt;/a&gt; to automate the parts that kept breaking. The whole workflow now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;i1n push &lt;span class="nt"&gt;--translate&lt;/span&gt; es,fr,de,ja,pt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Variables stay untouched in every language. And it generates a &lt;code&gt;.d.ts&lt;/code&gt; file, so if I typo a key, TypeScript catches it before I even run the app.&lt;/p&gt;

&lt;p&gt;But the real shift was adding an MCP server. If you use Cursor or Claude Code, you can tell your agent "internationalize this component" and it extracts the strings, translates, and rewrites the code. I was skeptical about this one. For repetitive extraction work, it's genuinely good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 85% problem
&lt;/h2&gt;

&lt;p&gt;AI translation isn't perfect though. It gets context wrong on short strings ("Save" as a verb vs "Save" as a noun), goes too literal on idioms, and sometimes loses tone completely. I've seen it translate "Drop us a line" as the equivalent of "drop a rope" in Japanese.&lt;/p&gt;

&lt;p&gt;That's why I ended up building a dashboard too. The AI handles the first pass, you review what it generated, fix what needs fixing, and mark those as approved so they don't get overwritten next time. If your source text changes later, the system flags which translations went stale.&lt;/p&gt;

&lt;p&gt;85% automation, 15% human review. That split turned a full day of work into maybe 20 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone setting up i18n today
&lt;/h2&gt;

&lt;p&gt;Don't start with the translation. Start with the workflow. Figure out how you're going to keep files in sync, how you'll catch missing keys before production, and how you'll handle variables across languages. The translation is the easy part.&lt;/p&gt;

&lt;p&gt;The CLI and dashboard are open source (MIT) if you want to try it: &lt;a href="https://i1n.ai" rel="noopener noreferrer"&gt;i1n.ai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How are you handling translations? I keep running into teams that either set up a full TMS they barely use, or just skip internationalization entirely because the workflow is too painful.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I told my AI agent "internationalize this" and it actually did it</title>
      <dc:creator>Franco Ortiz</dc:creator>
      <pubDate>Tue, 31 Mar 2026 13:37:36 +0000</pubDate>
      <link>https://dev.to/pakvothe/i-told-my-ai-agent-internationalize-this-and-it-actually-did-it-45on</link>
      <guid>https://dev.to/pakvothe/i-told-my-ai-agent-internationalize-this-and-it-actually-did-it-45on</guid>
      <description>&lt;p&gt;I've set up i18n in Next.js App Router projects more times than I'd like to admit. It's always the same: middleware config, a pile of JSON files, wiring up the provider, copy-pasting keys between locales, discovering at 11pm that &lt;code&gt;common.heor.title&lt;/code&gt; returns undefined in production.&lt;/p&gt;

&lt;p&gt;The last time it happened I was mass-translating 40+ keys across 5 languages by hand. I stopped halfway through and thought: why am I still doing this manually?&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://i1n.ai/en" rel="noopener noreferrer"&gt;i1n&lt;/a&gt;, an open source CLI (MIT) that pushes your translation keys and gets them back translated with full TypeScript types. Free tier, no credit card, no 14-day trial tricks.&lt;/p&gt;

&lt;p&gt;Let me walk you through the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Next.js project (App Router or Pages Router)&lt;/li&gt;
&lt;li&gt;Node.js 18+&lt;/li&gt;
&lt;li&gt;~5 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Install and init
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; i1n
i1n init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It'll ask for an API key (you can grab one at &lt;a href="https://dashboard.i1n.ai/login" rel="noopener noreferrer"&gt;i1n.ai&lt;/a&gt;, Google or GitHub login). After that it detects your framework and sets up the locale directory automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write your source strings
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;locales/en_us/common.json&lt;/code&gt;:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hero"&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome to our app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The best tool for {task}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Get Started"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nav"&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"home"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"about"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"About"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pricing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pricing"&lt;/span&gt;&lt;span class="w"&gt;
  &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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing new here — same JSON structure you're probably used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Push and translate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;i1n push &lt;span class="nt"&gt;--translate&lt;/span&gt; es,fr,de,ja
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. It pushes your keys, translates to 4 languages, and generates &lt;code&gt;i1n.d.ts&lt;/code&gt; with autocomplete for every key. Variables like &lt;code&gt;{task}&lt;/code&gt; are left untouched in every language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;i1n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Hero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hero.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hero.subtitle&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="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;building products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hero.cta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;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;If you typo a key, TypeScript yells at you before it ever hits production. No more &lt;code&gt;t('hera.titl')&lt;/code&gt; silently rendering nothing.&lt;/p&gt;

&lt;p&gt;If you're already on i18next or next-intl, there's a Bridge Mode that wraps your existing setup with type safety. One line, no migration. &lt;a href="https://i1n.ai/en/docs/bridge-mode/" rel="noopener noreferrer"&gt;Details in the docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: MCP Server
&lt;/h2&gt;

&lt;p&gt;This part is honestly the most fun. If you use Cursor, Claude Code, or Windsurf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add i1n &lt;span class="nt"&gt;--&lt;/span&gt; npx i1n mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can just tell your AI agent &lt;em&gt;"internationalize this component"&lt;/em&gt; and it actually does it. Reads the file, extracts the strings, pushes them, translates everything, rewrites the code with &lt;code&gt;t()&lt;/code&gt; calls. What used to take me an hour is like 30 seconds now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does your i18n setup look like?&lt;/strong&gt; I've been going back and forth between next-intl and i18next with the App Router and I'm curious what other people landed on.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
