<?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: Bosi (Terry) Lin</title>
    <description>The latest articles on DEV Community by Bosi (Terry) Lin (@lb0ss).</description>
    <link>https://dev.to/lb0ss</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%2F4240171%2Fee9c09a9-133d-41d4-b627-f4e6aa0e8ae6.png</url>
      <title>DEV Community: Bosi (Terry) Lin</title>
      <link>https://dev.to/lb0ss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lb0ss"/>
    <language>en</language>
    <item>
      <title>Wrapping strings in t() was the easy part of translating my React app</title>
      <dc:creator>Bosi (Terry) Lin</dc:creator>
      <pubDate>Thu, 12 Nov 2026 04:33:00 +0000</pubDate>
      <link>https://dev.to/lb0ss/wrapping-strings-in-t-was-the-easy-part-of-translating-my-react-app-28lf</link>
      <guid>https://dev.to/lb0ss/wrapping-strings-in-t-was-the-easy-part-of-translating-my-react-app-28lf</guid>
      <description>&lt;p&gt;I tried getting an AI agent to internationalize a side project and spent longer checking its diff than it would have taken me to do it myself. It did the work. It also picked the languages, decided where the language files went, and chose Simplified Chinese without asking. None of that was wrong exactly, but I had to read every changed file to find out.&lt;/p&gt;

&lt;p&gt;So I started doing it properly, and then I built a tool for it. This post is mostly the first part: the things that make i18n harder than "wrap every string in &lt;code&gt;t()&lt;/code&gt;". The tool comes at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring part
&lt;/h2&gt;

&lt;p&gt;For a React app that wasn't built with translation in mind, the work looks like this, for every piece of text:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the string in the JSX&lt;/li&gt;
&lt;li&gt;Make up a key for it&lt;/li&gt;
&lt;li&gt;Replace it with &lt;code&gt;t('the_key')&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add the key and the English text to &lt;code&gt;en.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Get it translated into every other language file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 to 4 are tedious but easy. Step 5 is where things break, and mostly not in ways you'd notice by looking at the translated text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plurals aren't "one or many"
&lt;/h2&gt;

&lt;p&gt;English has two forms: 1 item, 2 items. So you write two keys and move on. Plenty of languages need more, and Russian shows the problem best: it has four. The form depends on the last digits of the number, so 1, 21 and 101 all use one form, 2 to 4 another, 5 to 20 a third. i18next handles this with suffixed keys, and the Russian file needs all of them:&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="nl"&gt;"items_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"{{count}} товар"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"items_few"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"{{count}} товара"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"items_many"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"{{count}} товаров"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"items_other"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"{{count}} товара"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arabic has six (&lt;code&gt;_zero&lt;/code&gt;, &lt;code&gt;_one&lt;/code&gt;, &lt;code&gt;_two&lt;/code&gt;, &lt;code&gt;_few&lt;/code&gt;, &lt;code&gt;_many&lt;/code&gt;, &lt;code&gt;_other&lt;/code&gt;). Chinese and Japanese have one.&lt;/p&gt;

&lt;p&gt;If your Russian file only has &lt;code&gt;items_one&lt;/code&gt; and &lt;code&gt;items_other&lt;/code&gt; because that's what English had, nothing errors. For 5 items i18next looks for &lt;code&gt;items_many&lt;/code&gt;, doesn't find it, and falls back: depending on your settings, Russian users get the English sentence or a missing-key string in the middle of the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placeholders get translated too
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;"Hi {{name}}, you have {{count}} messages"&lt;/code&gt; contains two things that must come out exactly as they went in. Machine translation, and AI models especially, will sometimes translate the placeholder name along with the sentence: &lt;code&gt;{{name}}&lt;/code&gt; becomes &lt;code&gt;{{nombre}}&lt;/code&gt;. The JSON is still valid, and the app now shows an empty gap where the name should be.&lt;/p&gt;

&lt;p&gt;The fix isn't a better prompt. It's checking every translation against the source: same placeholders, same count, and rejecting it if they differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markup in the middle of a sentence
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt; &lt;span class="n"&gt;agree&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;our&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/terms"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Terms&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can't translate "By signing up you agree to our" and "Terms" separately, because other languages put the link somewhere else in the sentence. In react-i18next this becomes &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt;, and the translation file keeps the tag as a number:&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;"agree_terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"By signing up you agree to our &amp;lt;1&amp;gt;Terms&amp;lt;/1&amp;gt;."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translators (human or not) can move &lt;code&gt;&amp;lt;1&amp;gt;…&amp;lt;/1&amp;gt;&lt;/code&gt; wherever the grammar needs it, as long as they don't drop or rename it. Another thing worth checking automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every string is text
&lt;/h2&gt;

&lt;p&gt;A lot of strings in a React codebase look like text and aren't: &lt;code&gt;className="flex items-center"&lt;/code&gt;, URLs, &lt;code&gt;console.log&lt;/code&gt; messages, and product names like "GitHub" or "Markdown" that should stay as they are. Translate those and you get broken styling, or a German UI that talks about "Abschlag".&lt;/p&gt;

&lt;h2&gt;
  
  
  Choices nobody tells you you're making
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;zh&lt;/code&gt; isn't one language for this purpose. Simplified (mainland China, Singapore) and Traditional (Taiwan, Hong Kong) are written differently, and people notice.&lt;/li&gt;
&lt;li&gt;Where the language files live (&lt;code&gt;src/locales&lt;/code&gt;, &lt;code&gt;public/locales&lt;/code&gt;) depends on how you load them.&lt;/li&gt;
&lt;li&gt;If you use Next.js with the App Router, &lt;code&gt;useTranslation()&lt;/code&gt; doesn't work in server components at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent will make these choices for you, quietly. They're all fine to make. I'd just rather make them myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ended up building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=BosiTerryLin.guided-i18n" rel="noopener noreferrer"&gt;guided-i18n&lt;/a&gt; is a VS Code extension that does those five steps with a review in the middle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nto692df1vvdkoutu0h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nto692df1vvdkoutu0h.gif" alt="guided-i18n turning hardcoded text into react-i18next keys" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A parser finds the strings locally and skips the ones that aren't text.&lt;/li&gt;
&lt;li&gt;You get a checklist of every string and the key it'll get, before anything changes. Untick what should stay hardcoded.&lt;/li&gt;
&lt;li&gt;It rewrites the code to &lt;code&gt;t()&lt;/code&gt; or &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt;, writes the JSON files, and translates them with your own API key (Anthropic, or anything OpenAI-compatible).&lt;/li&gt;
&lt;li&gt;Every translation is checked for placeholders, tags and plural forms. A failure gets one retry, then it's reported instead of written.&lt;/li&gt;
&lt;li&gt;Undo reverts the whole run, code and language files together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwme52g3bpilcec9ihac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwme52g3bpilcec9ihac.png" alt="The checklist: every string and its key, before anything changes" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only the strings and a little context (file path, component name) go to the model, never whole files. A single file usually costs cents. The biggest project I tried it on was about 840 files and cost a few dollars.&lt;/p&gt;

&lt;p&gt;Honest limits: React with react-i18next and JSON files only, for now. If you already use an AI agent and don't mind reviewing its diff, it will cover more odd cases than this does. This is for when you'd rather see every change before it happens.&lt;/p&gt;

&lt;p&gt;It's free (you pay your AI provider directly), and it's on the VS Code Marketplace and on Open VSX for Cursor and Windsurf.&lt;/p&gt;

&lt;p&gt;If you try it on your codebase, I'd like to hear where it gets things wrong: &lt;a href="https://github.com/lb0ss/guided-i18n/issues" rel="noopener noreferrer"&gt;GitHub issues&lt;/a&gt; or the comments here.&lt;/p&gt;

</description>
      <category>react</category>
      <category>i18n</category>
      <category>vscode</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
