Wrapping strings in t() was the easy part of translating my React app
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 fil
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.
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 t()". The tool comes at the end.
The boring part
For a React app that wasn't built with translation in mind, the work looks like this, for every piece of text:
- Find the string in the JSX
- Make up a key for it
- Replace it with
t('the_key') - Add the key and the English text to
en.json - Get it translated into every other language file
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.
Plurals aren't "one or many"
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:
{
"items_one": "{{count}} ΡΠΎΠ²Π°Ρ",
"items_few": "{{count}} ΡΠΎΠ²Π°ΡΠ°",
"items_many": "{{count}} ΡΠΎΠ²Π°ΡΠΎΠ²",
"items_other": "{{count}} ΡΠΎΠ²Π°ΡΠ°"
}
Arabic has six (_zero, _one, _two, _few, _many, _other). Chinese and Japanese have one.
If your Russian file only has items_one and items_other because that's what English had, nothing errors. For 5 items i18next looks for items_many, 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.
Placeholders get translated too
"Hi {{name}}, you have {{count}} messages" 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: {{name}} becomes {{nombre}}. The JSON is still valid, and the app now shows an empty gap where the name should be.
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.
Markup in the middle of a sentence
<p>By signing up you agree to our <a href="/terms">Terms</a>.</p>
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 <Trans>, and the translation file keeps the tag as a number:
"agree_terms": "By signing up you agree to our <1>Terms</1>."
Translators (human or not) can move <1>β¦</1> wherever the grammar needs it, as long as they don't drop or rename it. Another thing worth checking automatically.
Not every string is text
A lot of strings in a React codebase look like text and aren't: className="flex items-center", URLs, console.log 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".
Choices nobody tells you you're making
-
zhisn't one language for this purpose. Simplified (mainland China, Singapore) and Traditional (Taiwan, Hong Kong) are written differently, and people notice. - Where the language files live (
src/locales,public/locales) depends on how you load them. - If you use Next.js with the App Router,
useTranslation()doesn't work in server components at all.
An agent will make these choices for you, quietly. They're all fine to make. I'd just rather make them myself.
What I ended up building
guided-i18n is a VS Code extension that does those five steps with a review in the middle:
- A parser finds the strings locally and skips the ones that aren't text.
- You get a checklist of every string and the key it'll get, before anything changes. Untick what should stay hardcoded.
- It rewrites the code to
t()or<Trans>, writes the JSON files, and translates them with your own API key (Anthropic, or anything OpenAI-compatible). - Every translation is checked for placeholders, tags and plural forms. A failure gets one retry, then it's reported instead of written.
- Undo reverts the whole run, code and language files together.
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.
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.
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.
If you try it on your codebase, I'd like to hear where it gets things wrong: GitHub issues or the comments here.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.
