TL;DR
I had a React app with roughly 3,400 hardcoded English strings scattered across 600+ components, and a mandate to ship a second language. Regex codemods handled maybe 60% of it and quietly mangled the rest, so I split the job: a deterministic AST codemod for the mechanical part, and Claude Code for the judgment calls it kept getting wrong. Here's the split that worked, the three failure modes that cost me a week, and what I'd do differently.
The Problem
Internationalization is the most boring hard problem in frontend work.
The pitch sounds trivial: find every user-visible string, move it into a resource file, replace it with a t() call. A junior dev could do it. In practice I was staring at a five-year-old React codebase with:
- ~3,400 user-visible strings across 620 component files
- Strings living in JSX text,
titleattributes,aria-labels,placeholders, thrownErrormessages, toast helpers, and — my favorite — a 400-lineconstants.jsthat mixed UI copy with API endpoint paths - Zero consistency in how text was composed: template literals, string concatenation, and a homegrown
formatMessage()helper that predated anyone currently on the team - Plurals handled by
count === 1 ? 'item' : 'items'in 90-odd places
Estimated by hand: three to four weeks of tedium, with a near-certainty that I'd miss strings and ship a half-translated UI.
The interesting constraint wasn't volume. It was that the task is 80% mechanical and 20% judgment, and the two are interleaved at random. A pure codemod can't tell you whether "Save" in a modal footer and "Save" in a toolbar should share a translation key. An LLM can, but you do not want an LLM hand-editing 620 files one at a time — that's slow, expensive, and non-reproducible.
So the real question became: where exactly is the line between "script it" and "ask the model"?
How I Solved It
I ended up with a three-pass pipeline. Each pass has a different tolerance for being wrong.
flowchart LR
A[Pass 1: AST extract] --> B[strings.json<br/>+ call sites]
B --> C[Pass 2: agent<br/>key naming + triage]
C --> D[Pass 3: AST rewrite<br/>codemod]
D --> E[Type-check + tests + diff review]
E -->|failures| C
Pass 1 — Deterministic extraction (no LLM)
I wrote a Babel-based extractor that walks every .jsx/.tsx file and emits a JSON record for each string literal that could plausibly be user-visible, along with enough context for a human or model to judge it later.
// scripts/extract-strings.mjs — Node.js 22.x, @babel/parser 7.x
import { parse } from '@babel/parser'
import _traverse from '@babel/traverse'
const traverse = _traverse.default
const ATTRS = new Set(['title', 'placeholder', 'aria-label', 'alt', 'label'])
export function extractFromSource(code, filename) {
const ast = parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
})
const found = []
traverse(ast, {
JSXText(path) {
const raw = path.node.value.trim()
if (!raw || !/[a-zA-Z]/.test(raw)) return
found.push({
filename,
line: path.node.loc.start.line,
kind: 'jsx-text',
value: raw,
component: nearestComponentName(path),
})
},
JSXAttribute(path) {
const name = path.node.name.name
if (!ATTRS.has(name)) return
const v = path.node.value
if (v?.type !== 'StringLiteral') return
found.push({
filename,
line: v.loc.start.line,
kind: `attr:${name}`,
value: v.value,
component: nearestComponentName(path),
})
},
})
return found
}
This pass is allowed to over-collect. False positives are cheap to drop later; false negatives are strings that silently stay in English forever. I tuned it toward noise and it pulled about 4,100 candidates for 3,400 real strings.
Crucially, the extractor never modifies anything. It produces data. That makes it safe to run a hundred times while you're still figuring out the rules.
Pass 2 — The agent does the judgment
This is where Claude Code earned its keep. I fed it the extracted JSON in batches of ~80 strings, grouped by directory so related components landed in the same batch, and asked for exactly one thing per string: a decision.
The prompt that finally worked was uncomfortably specific:
For each entry, output one of:
KEY — user-visible copy. Propose a key as <feature>.<component>.<slug>.
If an identical string already has a key in the provided key list,
reuse that key ONLY if the surrounding component context suggests
the same meaning. Different meaning => new key, even if identical text.
SKIP — not user-visible (dev-only log, CSS class, test id, API path,
icon name, enum value used for logic).
FLAG — you cannot tell from the given context. Explain in one line.
Never guess between KEY and SKIP. FLAG is always the correct answer
when you are unsure. You will not be penalized for flagging.
That last paragraph is doing almost all the work. My first three attempts had no FLAG option, and the model dutifully classified every ambiguous string with total confidence — including turning a Stripe webhook event name into a translation key, which would have been a genuinely fun production incident.
With FLAG available, it flagged 212 of 4,100 entries. I reviewed those by hand in about 40 minutes. Every single flag was a real ambiguity. The model was better at knowing what it didn't know than at knowing things, and the pipeline only got good once I designed around that.
Output looked like this:
{ "id": "a41c", "decision": "KEY",
"key": "billing.invoiceList.emptyState",
"value": "No invoices yet" }
{ "id": "a41d", "decision": "SKIP",
"reason": "value is a data-testid selector" }
{ "id": "a41e", "decision": "FLAG",
"reason": "\"Complete\" — could be a status label or a button verb" }
Pass 3 — Deterministic rewrite (no LLM)
The rewriter takes the decision file and applies it with another AST pass. The model never touches source code directly.
This is the part I'd argue hardest for. It buys you three things:
- Reproducibility. Re-running produces a byte-identical diff. When review caught a bad key name, I fixed one line in the decision file and regenerated instead of hunting through 620 files.
- A real safety net. The rewriter refuses to run on any file where the extractor's recorded line/column no longer matches the current source — so a stale decision file fails loudly instead of corrupting a component.
- Cost. Three thousand-odd rewrite operations cost nothing and take eleven seconds.
Interpolation was the one place I couldn't stay purely mechanical. Strings like:
<p>Welcome back, {user.firstName}! You have {count} new messages.</p>
need to become a single parameterized key, not three fragments — fragment-splitting is exactly how you get translations that are grammatically impossible in languages with different word order. So the extractor detects JSXExpressionContainer siblings inside one element and emits the whole element as a single template candidate:
{ "kind": "template",
"template": "Welcome back, {{firstName}}! You have {{count}} new messages.",
"params": { "firstName": "user.firstName", "count": "count" } }
The agent names the key; the rewriter emits t('home.greeting', { firstName: user.firstName, count }). About 190 strings took this path, and hand-writing them would have been the single most miserable day of the project.
Verification
Nothing shipped on vibes. After each rewrite pass:
-
tsc --noEmit— catches wrong param names int()calls - Full test suite — catches anything a test asserted on by visible text
- A generated report of any remaining literal in the extractor's candidate set — this is the anti-false-negative check, and it's the only reason I trust the coverage number
- A pseudo-locale build that wraps every translated string in
[[ ]]and pads it 40% longer, then a Playwright pass over the main flows. Anything rendering bare, unbracketed English is a string I missed. Anything overflowing its container is a layout bug German was going to find for me anyway.
The pseudo-locale pass found 61 strings the extractor never saw — mostly text built in utility functions far from any JSX. I'd have shipped without them.
Lessons Learned
1. Give the model an "I don't know" exit, or it will invent confidence.
The single highest-leverage change I made was adding FLAG and explicitly stating it carried no penalty. Classification accuracy on the remaining decisions went from "needs full review" to "spot-check a sample." If your prompt forces a binary choice on genuinely ambiguous input, you're not measuring the model's judgment — you're measuring its willingness to guess.
2. Let the LLM decide, let a script apply.
Every mechanical edit an agent performs by hand is an edit you can't reproduce, can't diff cleanly, and can't cheaply redo. Push the model toward producing decisions as data and keep code modification in deterministic tooling. This also collapses your review surface: reviewing 4,100 JSON lines is genuinely faster than reviewing a 12,000-line diff.
3. Over-collect in extraction, filter later.
False positives cost seconds. False negatives ship to production and sit there for a year. I biased the extractor toward noise and never regretted it — the 700 junk candidates were dispatched by the agent in a couple of batches.
4. Batch by semantic locality, not by file size.
My first run batched strings in extraction order, which scattered related components across batches. The model couldn't tell that "Save" in one batch and "Save" in another were the same button, and I got duplicate keys. Regrouping by directory cut duplicate key proposals by roughly two-thirds. Context adjacency matters more than batch size.
5. Your verification has to be able to find what you didn't ask for.
Type-checks and tests only validate the strings you did extract. The pseudo-locale build was the only check that could surface strings the whole pipeline never knew about — and it found 61. Whenever you automate a sweep across a codebase, budget real effort for a check that answers "what did I miss?", not just "is what I did correct?"
What's Next
Two things I want to fix:
The extractor is React-specific and I'd like the same decision-file pattern applied to our server-side error messages, which have the same problem with worse consequences. And the flagged-string review is still fully manual — I think a second agent pass with the rendered component screenshot as context could resolve maybe half of them, since most ambiguity ("is 'Complete' a status or a verb?") is instantly obvious the moment you see the UI.
Longer term, I'm convinced this three-pass shape — deterministic extract → agent decides → deterministic apply — generalizes well past i18n. It's the same structure I'd use for a dependency-API migration, a logging-convention sweep, or a design-token rollout. The expensive, non-reproducible part of any large migration is judgment, and that's exactly the part worth spending model tokens on.
Total elapsed: four days, versus my three-to-four-week hand estimate. About six hours of that was me reviewing flags and diffs. The rest was the pipeline running.
Wrap-up
If you're facing a large mechanical-but-not-quite migration, resist the urge to point an agent at the repo and say "do the thing." Split it. Let the deterministic tools do what they're perfect at, and spend the model on the 5% that actually needs a brain.
If you try this pattern on something other than i18n, I'd genuinely like to hear how it goes — drop it in the comments. And if you found this useful, follow me here on Dev.to; I write up these build logs as I go.
Stack notes for anyone reproducing this: Claude Code (Aug 2026 release), Node.js 22.x, @babel/parser 7.x, TypeScript 5.x, Playwright 1.5x.
Top comments (0)