I dumped semantic i18n keys. Now the source text itself is the key — and LLMs do the rest.
The thing everyone does (and why we stopped) If you've ever built a non-trivial UI in a second language, you've written code like this: t('features.localeFiles.title') t('features.localeFiles.description') Sem
The thing everyone does (and why we stopped)
If you've ever built a non-trivial UI in a second language, you've written code like this:
t('features.localeFiles.title')
t('features.localeFiles.description')
Semantic keys. Dot-separated, namespaced, "proper". It's the pattern every i18n library teaches you: i18next, vue-i18n, react-i18next, all of them. You write a key, you look up the translation in a JSON file, done.
For a long time I did the same thing. Then I hit a wall: our engineers write the UI copy themselves, we're shipping to multiple languages, and we're using LLMs to do the bulk of the translation. In that world, semantic keys started to feel like pure overhead.
So I tried something that sounds weird at first: use the source string itself as the key.
t('Language resource files')
t('Translations live in src/locales/*.json — the key is the source string.')
No features.localeFiles.title. No jumping between files to remember what a key meant. The source string is the source of truth, and the locale file is just { "<source>": "<translation>" }.
The source language can be English, Chinese, Spanish — whatever your team writes the product in. The target locales are whatever you ship to. In our case the source is English and we're translating to Spanish (next up: Japanese), but the mechanism doesn't care.
This isn't a new idea — lots of teams do it — but what I haven't seen much of is a proper CLI workflow around it, especially one that integrates LLMs without losing the safety net of CI checks. So I built one: @10coding/i18n-trans.
Let me walk through the problems with semantic keys, the runtime contract I settled on, and the three commands that actually run the workflow.
Why semantic keys started to hurt us
Semantic keys aren't wrong. They're a great fit for teams with a dedicated localization team, where copy is written by product managers in a CMS and engineers never see the source string. But if your engineers maintain both the code and the translations, the costs add up:
-
Code and copy live in two places. Reading
features.localeFiles.titletells you nothing. You have to open the JSON file, find the key, and only then remember what the screen says. -
LLMs translate badly without context. When you hand a model the key
cta.bookand the source "Book", it has no idea whether this is "a book you're reading" or "book a flight". It'll happily give you "Libro" in both cases — wrong for one of them. -
One word, two translations, one key. English "Book" is sometimes
Libro, sometimesReservar. A single semantic key can only hold one translation. You end up inventingcta.book_nounandcta.book_verbto disambiguate — for a single English word. - Missing keys and dead keys accumulate. Code references a key that isn't in the JSON, or the JSON has a key that no code uses anymore. Without tooling, you don't find out until a user sees the raw key on screen.
- Cross-framework pain. React uses i18next, Vue uses vue-i18n. Different key conventions, different interpolation syntax. Sharing locale files across both is a mess.
The source-text-as-key approach doesn't fix all of these. But it turns the ones it does fix into trivial problems.
The data format
It's deliberately boring:
- The source locale file is an identity map — source → source.
- Target locale files use the exact same keys; the value is the translation.
- Keys are flat strings. No nesting, no dot-separated namespaces.
// src/locales/en-US.json
{
"Hello, {name}!": "Hello, {name}!",
"Book": "Book"
}
// src/locales/es-ES.json
{
"Hello, {name}!": "¡Hola, {name}!",
"Book": "Libro",
"Book__TRANS_ID_reserve": "Reservar"
}
Call sites look like this:
t('Hello, {name}!', { name: 'Ada' })
t('Book') // Libro (a book you read)
t('Book', 'reserve') // Reservar (book a flight / a table)
The wins are immediate:
- The code is the copy. Open a component, read the source string, you know exactly what the screen says.
- The source text is already the LLM input. No separate "key → source string" lookup table to maintain.
- Flat JSON means diffs are trivial, dead keys are obvious.
-
__TRANS_IDsuffixes let one source string have multiple translations for different contexts (more on this below).
The trade-off is real and I want to be upfront about it: if you change the source string, you've changed the key. The old translation becomes orphaned. We'll come back to this.
Runtime contract: no runtime package, just a CLI
Here's a decision I spent a while on. I did not publish a React or Vue runtime. I only ship a CLI.
Reason: runtimes are a trap. Vite vs Webpack vs SSR vs micro-frontends, different i18next versions, different vue-i18n configs. If I wrap a "universal" runtime around all that, I'll either cover 60% of cases or fight with every framework's existing i18n setup.
So i18n-trans constrains exactly two things:
- The shape of the locale JSON files.
- The contract for
t()— its signature and the lookup order.
type Translate = (
source: string,
options?: { __TRANS_ID?: string | number; [param: string]: unknown } | string | number,
) => string
Lookup order:
target locale: <source>__TRANS_ID_<id>
→ target locale: <source>
→ source locale: <source>
→ the source string itself
When the active language is the source language, just return the source string.
You can implement this on top of i18next, vue-i18n, or a 30-line hand-rolled function. Doesn't matter. As long as the lookup order matches, the CLI works.
The three commands
That's the whole surface area:
i18n-trans check # audit source and target locales
i18n-trans check --add --add-target --remove # auto-fix
i18n-trans trans # LLM translation pass
check: static scanning + diff
The CLI uses Babel to statically find t() / translate() calls in your source. It supports:
- TypeScript / TSX
- Vue SFC
<script>and<script setup> - Vue templates — both
{{ t('Save') }}and bound attributes like:title="t('Book', 'reserve')"
No runtime needed, no dev server. It runs in CI the same way it runs locally.
When you run i18n-trans check, it reports four classes of issues:
-
Source missing: a
t('...')call whose key isn't in the source locale. - Source unused: a key in the source locale that no code references.
- Target missing: a source key with no translation in a target locale.
- Target extra: a key in a target locale that no longer exists in the source.
Flags make it self-healing:
-
--add: add missing source keys with identity values. -
--add-target: add missing target keys with a⟦TODO⟧ <source>placeholder. -
--remove: delete unused / orphaned keys.
If a target locale file doesn't exist, it just creates one.
trans: batch LLM translation
i18n-trans trans
It batches pending keys and posts them to any OpenAI-compatible endpoint (OpenAI, DeepSeek, local vLLM, whatever), defaulting to 20 units per batch, serially.
Config lives in .env:
I18N_LLM_BASE_URL=https://api.openai.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=gpt-4o-mini
A full demo
Let's run the whole workflow on a tiny project. Here's src/App.tsx:
export const greeting = t('Hello')
export const save = t('Save')
export const reserve = t('Book', 'reserve')
Initial state:
// src/locales/en-US.json
{
"Hello": "Hello"
}
// src/locales/es-ES.json
{
"Hello": "Hola",
"Legacy copy": "Copia antigua"
}
I've deliberately planted three problems: the source locale is missing Save and Book, the target is missing their translations (plus the reserve variant), and the target has an orphaned Legacy copy.
Step 1 — check
$ i18n-trans check
i18n check · 1 files scanned · 3 static t() keys
en-US (source) · src/locales/en-US.json
✖ missing 2 key(s)
- Save (src/App.tsx:2)
- Book (src/App.tsx:3)
es-ES (target) · src/locales/es-ES.json
✖ missing 1 translation(s)
- Book__TRANS_ID_reserve
⚠ extra 1 key(s) (not in the source locale)
- Legacy copy
✖ check failed: missing keys remain
A few things to notice:
- Missing keys come with a
file:lineso your editor can jump straight to them. -
Book__TRANS_ID_reserveis the variant key the scanner derived fromt('Book', 'reserve'). -
Legacy copyis flagged as extra because it no longer exists in the source. - Exit code is
1when anything is wrong, so this drops into a CI gate.
Step 2 — fix
$ i18n-trans check --add --add-target --remove
The CLI first prints the audit, then the mutations:
+ en-US: added 2 key(s) in src/locales/en-US.json
+ es-ES: added 3 key(s) in src/locales/es-ES.json
- es-ES: removed 1 key(s) in src/locales/es-ES.json
✔ check passed
Diff:
// src/locales/en-US.json
{
"Hello": "Hello",
+ "Save": "Save",
+ "Book": "Book"
}
// src/locales/es-ES.json
{
"Hello": "Hola",
- "Legacy copy": "Copia antigua"
+ "Save": "⟦TODO⟧ Save",
+ "Book": "⟦TODO⟧ Book",
+ "Book__TRANS_ID_reserve": "⟦TODO⟧ Book"
}
⟦TODO⟧ means "key exists, no translation yet". It's a placeholder, not a translation. Running check again now passes clean — but the target locale still has no real translations.
Step 3 — translate
First a dry run to see what would be sent:
$ i18n-trans trans --dry-run
i18n trans · en-US → es-ES
dry run: 3 unit(s) to translate
- Save
- Book
- Book__TRANS_ID_reserve
Hello already has a translation (Hola), so it's skipped. Real run:
$ i18n-trans trans
i18n trans · en-US → es-ES
[es-ES] 3 unit(s)
[es-ES] ✔ 3 translated
translated 3, failed 0, skipped 1
Result:
{
"Hello": "Hola",
"Save": "⟦REVIEW⟧ Guardar",
"Book": "⟦REVIEW⟧ Libro",
"Book__TRANS_ID_reserve": "⟦REVIEW⟧ Reservar"
}
New translations are written with a ⟦REVIEW⟧ prefix. That's deliberate — more on it below. The skipped 1 is the already-translated Hello.
What actually goes to the LLM
The CLI doesn't dump the entire locale file at the model. It builds per-unit translation requests:
scan t() calls
→ derive expected keys (source key + __TRANS_ID variants)
→ load target locale JSON
→ find untranslated units
→ build unit { key, source, transId, context, hint }
→ batch by batchSize
→ POST /chat/completions
→ parse result
→ validate keys, non-empty, placeholders
→ write back as ⟦REVIEW⟧ translations
→ re-run check
What counts as "untranslated"
A key is sent to the model if:
- It's missing from the target locale, or
- Its value is a
⟦TODO⟧ <source>placeholder, or - Its value equals the source string (handles legacy data).
It's skipped if:
- It already has a clean translation,
- It's already marked
⟦REVIEW⟧(so you don't re-translate things you're about to review), - It's listed in
translation.ignoreKeys.
The unit shape
interface TranslationUnit {
key: string // "Book__TRANS_ID_reserve"
source: string // "Book"
transId?: string // "reserve"
context?: string // surrounding code at the call site
hint?: string // manual note from i18n.config.json
}
context is auto-extracted from a few lines around the t() call. hint is a manual override you can set per key.
The system prompt
The system message pins down the contract:
- Translate
source, notkey. - Return
{"results":[{"key":"...","value":"..."}]}. - Keys must match input exactly — no additions, no omissions.
- Preserve
{name}and{{name}}interpolation tokens, including brace style. - Preserve HTML tags, numbers, product names, code identifiers.
- Use
contextandhintto disambiguate. - JSON only — no markdown fences, no commentary.
Expected response:
{
"results": [
{ "key": "Save", "value": "Guardar" },
{ "key": "Book", "value": "Libro" },
{ "key": "Book__TRANS_ID_reserve", "value": "Reservar" }
]
}
The CLI also tolerates bare arrays or flat key/value maps, but results is the recommended shape.
Validation before writing anything
Three checks happen before a translation is written to disk:
- The returned key set matches the requested set exactly.
- Every value is a non-empty string.
- Interpolation tokens match between source and translation — same brace style, same count.
Any failure triggers a retry up to maxRetries. If it still fails, the batch is logged and nothing is written. Bad data never reaches the locale file.
Default batchSize is 20, runs serially. Smaller batches with context give more reliable output than one giant JSON blob, and failures are cheap to retry.
One word, two translations: __TRANS_ID
"Book" is the classic example:
- A book you're reading →
Libro - Book a flight or a restaurant table →
Reservar
With semantic keys you'd invent two separate keys. Here you just tag the call:
t('Book') // noun
t('Book', { __TRANS_ID: 'reserve' }) // verb
Target file:
{
"Book": "Libro",
"Book__TRANS_ID_reserve": "Reservar"
}
The scanner picks up the second argument and derives the variant key automatically.
The really useful part for LLMs is that you can attach a manual hint:
{
"translation": {
"hints": {
"Book__TRANS_ID_reserve": "This is the verb 'to book' — reserving a flight, hotel, or table. Not the noun 'a book you read'."
}
}
}
Now the model has a fighting chance to output "Reservar" instead of blindly copying "Libro".
The review markers you can't skip
LLM translations look great until they don't. Two markers make the human review step impossible to forget:
| Marker | Written by | Meaning |
|---|---|---|
⟦TODO⟧ <source> |
check --add-target |
Key exists, no translation yet |
⟦REVIEW⟧ <translation> |
trans |
AI translated, awaiting human review |
| no marker | human / trans --ignore
|
Confirmed translation |
trans writes ⟦REVIEW⟧ by default, not clean translations. Search your project for ⟦REVIEW⟧, go through each one, delete the marker when you're happy.
What this protects you from:
- Already-reviewed translations don't get re-translated.
- Existing translations are never overwritten.
- Interpolation tokens can't be silently dropped (validation catches it).
If you trust a batch and want clean output immediately, run trans --ignore.
Interpolation: {name} and {{name}} both work
i18next uses {{name}}. vue-i18n uses {name}. The CLI detects which style the source uses and requires the translation to keep it:
{
"Hello, {name}!": "¡Hola, {name}!",
"Welcome {{user}}!": "Bienvenido, {{user}}!"
}
If the model rewrites {{name}} as {name}, validation fails and the batch retries. No more broken interpolation in production.
Runtime-side, you'll usually want to align the syntax:
// i18next: match vue-i18n's single-brace style
i18n.init({
fallbackLng: 'en-US',
keySeparator: false,
nsSeparator: false,
interpolation: { prefix: '{', suffix: '}' },
})
// vue-i18n: source keys may contain . or /, so use a messageResolver
const i18n = createI18n({
legacy: false,
messageResolver: (obj, path) => (obj as Record<string, unknown>)[path] ?? null,
})
Quick start
pnpm add -D @10coding/i18n-trans
i18n.config.json:
{
"include": ["src/**/*.{ts,tsx,vue}"],
"localesDir": "src/locales",
"sourceLocale": "en-US",
"targetLocales": ["es-ES"],
"callers": ["t", "translate"],
"transId": {
"option": "__TRANS_ID",
"separator": "__TRANS_ID_"
}
}
Source locale:
// src/locales/en-US.json
{
"Hello, {name}!": "Hello, {name}!",
"Book": "Book"
}
LLM config (only needed for trans):
# .env
I18N_LLM_BASE_URL=https://api.openai.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=gpt-4o-mini
Optional knobs:
| Variable | Default | What it does |
|---|---|---|
I18N_LLM_TIMEOUT_MS |
60000 |
Per-request timeout |
I18N_LLM_MAX_RETRIES |
1 |
Retries per failed batch |
I18N_LLM_JSON_MODE |
true |
Requests response_format: json_object; set false if your provider doesn't support it |
Then:
i18n-trans check
i18n-trans check --add --add-target
i18n-trans trans
i18n-trans check
Who this is for (and who it isn't)
This clicks if:
- Your engineers write the UI copy themselves in whatever source language you've chosen, and they also maintain the translations.
- You have enough UI copy that missing / dead keys are already a recurring annoyance.
- You want LLMs doing draft translations, but you still want a human review gate.
- You're juggling multiple frameworks or repos and want one shared workflow.
- You accept that editing the source string means the old translation becomes stale.
This probably isn't for you if:
- You have a dedicated localization team that needs to work from a neutral key catalog, away from your source code.
- You need complex ICU pluralization, gender, or select rules — the contract here is deliberately simple.
- Your source copy churns constantly and you can't afford translation refresh work.
- You've already paid for a mature commercial i18n platform and don't need LLM integration.
The trade-offs, honestly
Why not semantic keys? They're not wrong — they're optimized for a different org structure: copy written outside the codebase, translated by a separate team. When the same engineers write the copy and the translations, the source string is already the best context an LLM can get. The indirection costs more than it saves.
Why no runtime package? Because every framework's i18n setup is a snowflake. The CLI constrains data shape and lookup order; you bring your own t() on top of i18next, vue-i18n, or a hand-rolled 30-line function. The repo ships reference implementations for React and Vue, but they're examples, not published packages.
Why keep the review markers? LLM output is good but not shippable-good. ⟦REVIEW⟧ turns "AI translated it" and "a human signed off" into two distinct, greppable states.
Wrapping up
The idea isn't "yet another i18n library". It's splitting i18n into three concerns:
- Data format: source string as key, flat JSON.
-
Runtime contract: a known
t()lookup order plus__TRANS_IDvariants. - Workflow: CLI scans, fills, and translates; humans review.
Your existing framework runtime stays in place. LLMs do the bulk translation. Markers and validation hold the quality line.
Links:
- GitHub: ALiuYiLin/i18n-llm-trans
- npm: @10coding/i18n-trans
- Contract:
docs/i18n-contract.md
If you're already doing source-string-as-keys and hand-rolling the plumbing, I'd love to hear what you've built. Star the repo if this saves you a few hours.
Tags: #webdev #typescript #react #vue #opensource #i18n #llm
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.