We decided to add multilingual support to a web application built with React.
Before starting, I thought it would be an easy task. Just replace the Japanese written directly in components,
showToast({
type: 'success',
message: 'プロジェクトを作成しました',
});
with t() like this, and be done.
showToast({
type: 'success',
message: t('project.createSuccess'),
});
When I actually got my hands dirty, I saw several problems that mere replacement wouldn't solve.
If the argument oft() is a string, nobody will stop a typo in a translation key.
t('field.naem');
Even if the translation requires {{entity}} and you forget to pass the interpolation argument, you won't notice until {{entity}}を作成しました shows up on screen.
t('crud.create.success');
Interpolation variable names can also drift between Japanese and English.
// ja
'{{entity}}を作成しました'
// en
'Created {{resource}}'
One more thing: this time the premise was that a Coding Agent writes the code. In a situation where AI changes dozens of files at once, creating a state where wrong code doesn't pass in the first place is more effective than hoping it writes things correctly.
So along with introducing i18n, I designed the following mechanisms.
- Manage translation resources in TypeScript
- Validate the translation structure across languages
- Don't expose i18next-specific APIs directly to the application
- Make translation keys and interpolation arguments type-safe
- Stop inconsistencies with tests and CI
- Detect untranslated hardcoded strings with lint
To give a sense of scale up front: when I applied my own lint rule to the existing code, there were more than 10,000 candidates for untranslated strings.
As a premise, this app is centered on React / TypeScript, and developers manage the translation resources themselves. I think the design in this article wouldn't fit as is in an environment where non-engineers manage translations.
Manage translation resources in TypeScript, not JSON
Managing translation resources in JSON is the common setup. Using i18next'sCustomTypeOptions, types can be applied to JSON resources as well. However, values imported from JSON widen to string, so they can't be used for extracting interpolation variables, described later. This time I wanted to put translation resources on TypeScript's type system, so I decided to write them in .ts.
export const commonJa = {
action: {
create: '作成',
update: '更新',
delete: '削除',
save: '保存',
cancel: 'キャンセル',
},
crud: {
create: {
success: '{{entity}}を作成しました',
failed: '{{entity}}の作成に失敗しました',
},
update: {
success: '{{entity}}を更新しました',
failed: '{{entity}}の更新に失敗しました',
},
delete: {
success: '{{entity}}を削除しました',
failed: '{{entity}}の削除に失敗しました',
},
},
} as const;
The key is the as const at the end. With it, '{{entity}}を作成しました' remains a string literal type instead of string. This type information is the foundation for the translation key type generation, IDE completion, and interpolation variable extraction described later.
Make translation keys English semantic keys
Keys are in English, with names that express meaning.
export const projectJa = {
name: 'プロジェクト',
field: {
name: 'プロジェクト名',
description: '説明',
},
message: {
cannotEditArchived:
'アーカイブ済みのプロジェクトは編集できません',
},
} as const;
There is a school of thought that uses the display string as the key, but I didn't adopt it.
t('プロジェクトを作成しました');
With this approach, changing "プロジェクトを作成しました" to "プロジェクトの作成が完了しました" changes the key too. I want to treat a translation key not as display text but as an ID that identifies meaning.
Make Japanese the source of truth for translation structure
Next I thought about keys missing across languages. If I addfield.description to Japanese, I want the type system to force the English side to have the same key.
Naively, using typeof projectJa as the English type seems fine, but then even the value name: 'プロジェクト' is required to match. All I want is a match of structure.
So I prepared a type that widens only the string leaves to string.
export type TranslationOf<T> =
T extends string
? string
: T extends object
? {
readonly [K in keyof T]: TranslationOf<T[K]>
}
: never;
The English side receives it with satisfies.
export const projectEn = {
name: 'Project',
field: {
name: 'Project name',
description: 'Description',
},
} as const satisfies TranslationOf<typeof projectJa>;
If you forget to write description,
export const projectEn = {
name: 'Project',
field: {
name: 'Project name',
},
} as const satisfies TranslationOf<typeof projectJa>;
TypeScript raises an error. Adding an extra key only to English is likewise an error. The relationship is that the Japanese resource is the source of truth for translation structure, and the other languages follow its shape.
Don't use i18next directly; put a thin Adapter in between
i18next has a Selector API, and TypeScript detects nonexistent keys.
const { t } = useTranslation('project');
t(($) => $.field.name);
// ✅ OK
t(($) => $.field.naem);
// ❌ Type Error
I could have used this as is. But this time, application code doesn't call react-i18next's useTranslation directly; I put a project-standard API in between. The argument is i18next's Namespace, in units like common and project.
const t = useAppTranslation('project');
t('field.name');
The reasons: I didn't want the dependency on i18next to spread across the whole application, and I wanted to stop "omitting interpolation arguments," which the Selector API doesn't stop. The latter is covered in the next section.
Application
│
│ useAppTranslation()
▼
Internal i18n Adapter
│
│ delegate
▼
react-i18next
│
▼
i18next
useAppTranslation isn't reimplementing a translation engine. Fallback, plurals, interpolation, and resource management are all left to i18next; what we own is only the API exposed to the application and the type contract. Even if i18next's API or type definitions change, the impact is confined inside the Adapter.
The rules for Coding Agents also state explicitly, "don't use useTranslation directly; use useAppTranslation." However, only this boundary is currently protected by instructions, with no layer to mechanically stop violations.
The Selector API checks interpolation variable names but doesn't stop omitting options
UsingcommonJa.crud.create.success ('{{entity}}を作成しました') from the beginning as an example, here is what I got trying the Selector API with i18next 26.4.2 on hand.
t(($) => $.crud.create.success);
// ✅ Passes
t(($) => $.crud.create.success, {
resource: 'X',
});
// ❌ Type error
t(($) => $.crud.create.success, {
entity: 'X',
});
// ✅ OK
Passing resource is a type error. In other words, i18next recognizes the interpolation variable name {{entity}} at the type level. On the other hand, the first one, which omits the options argument, passes. This is because options is optional in the type definition.
The behavior is "if you pass it, the contents are checked, but you aren't scolded for not passing it." I think this is intentional flexibility for a general-purpose library, but in this application I wanted to reliably stop cases where the argument is forgotten for a key that needs interpolation.
For keys requiring interpolation, make the second argument itself mandatory
Since the translation resources areas const, entity can be extracted at the type level from the string type '{{entity}}を作成しました'.
type InterpolationKeys<S extends string> =
S extends `${string}{{${infer Variable}}}${infer Rest}`
? Variable | InterpolationKeys<Rest>
: never;
Using this, the second argument is made mandatory only when interpolation variables exist.
type InterpolationArgs<V> =
V extends string
? [InterpolationKeys<V>] extends [never]
? []
: [
values: Record<
InterpolationKeys<V>,
string | number
>
]
: [];
As a result, t('crud.create.success') fails with Expected 2 arguments, but got 1. Wrong variable names are also stopped, and only correct calls pass.
t('crud.create.success', {
resource: 'Project',
});
// ❌ Type Error
t('crud.create.success', {
entity: 'Project',
});
// ✅ OK
The Adapter guarantees three things: the existence of the key, the interpolation variable names, and the presence of interpolation arguments. Honestly I also have a personal preference that t('foo.bar') reads better than t(($) => $.foo.bar), but the main goal was to confine the direct dependency on i18next while giving the application side a stricter contract.
Share CRUD wording
As i18n progresses, a large number of similar messages appear. "プロジェクトを作成しました", "ユーザーを作成しました", "チームを作成しました". Defining these per Entity asproject.message.createSuccess, user.message.createSuccess would produce nothing but duplication, so I consolidated mechanical CRUD wording into the commonJa.crud shown at the beginning.
On the consuming side, you fetch the Entity name from another Namespace and pass it in.
const commonT = useAppTranslation('common');
const projectT = useAppTranslation('project');
commonT('crud.create.success', {
entity: projectT('name'),
});
In English, you can change the word order, as in '{{entity}} was created successfully'. Building strings by concatenation can't handle differences in word order, so I always write them with interpolation.
Don't share wording that contains business rules
On the other hand, wording like "アーカイブ済みのプロジェクトは編集できません" isn't put incommon. That's because it contains a domain rule: "an archived Project can't be edited." This goes on the Entity side, like projectJa.message.cannotEditArchived above.
The criterion is as follows.
Mechanical wording that holds just by swapping the Entity name goes in common.
Once business knowledge is included, it belongs to the Entity / Feature side.
Place translation resources in FSD Slices too
When I delete the Entity Project, I want its translations to disappear with it. Since we adopted Feature-Sliced Design (FSD) this time, I didn't gather translation resources inlocales/; I placed them in the directory of the functional unit that uses the wording (a Slice, in FSD terms).
src/
├── shared/
│ └── i18n/
│ └── common/
│ ├── ja.ts
│ └── en.ts
│
└── entities/
└── project/
└── i18n/
├── ja.ts
├── en.ts
└── index.ts
The idea is not to manage translations as a separate world.
Check interpolation variable consistency across languages with tests, not types
I'm not trying to solve everything with types. For example, a case where Japanese is'{{entity}}の作成に失敗しました' and English is 'Failed to create {{resource}}'. The object structure is the same, so satisfies TranslationOf<typeof ja> passes. Doing it with types would mean adding interpolation variable comparison to TranslationOf, but I didn't want to make the types that complicated.
Instead, whether interpolation variables match across languages is checked in Vitest.
ja → entity
en → resource
→ Test Failed
What can be judged by looking at a single resource goes to TypeScript; what can't be known without comparing multiple resources goes to tests. This division is easier to maintain than continuing to add complex types.
Even with all this, untranslated hardcoded strings can't be detected
Even with types locked down this far, TypeScript says nothing if a developer doesn't uset() in the first place.
<Button>保存</Button>
This happens all the more with a Coding Agent. Even if it's written in the rules, when changing dozens of files at once, one spot or so slips through. So I considered whether hardcoded user-facing strings could be detected with lint.
The existing jsx-no-literals couldn't catch what I wanted
The first thing I tried was react/jsx-no-literals from Oxlint (a Rust-based linter that reimplements the main ESLint rules compatibly). Applying it to the existing codebase yielded thousands of violations, but looking inside, quite a few were strings hard to call translation targets, such as -, ( ), and Copyright ©. In multi-line JSX it sometimes reports at the opening tag, making it hard to trace which string is the problem.
Conversely, the following cases, which I wanted to catch this time, aren't detected.
{'保存しました'}
<Input placeholder="名前を入力してください" />
toast({
message: '保存しました',
});
z.string().min(
1,
'名前を入力してください',
);
"Is there a literal inside JSX?" and "Is there an untranslated user-facing string?" seem similar but are different questions.
I built my own rule with Oxlint's JS Plugin
I wrote a rule namedi18n/no-hardcoded-japanese with Oxlint's JS Plugin. It targets Literal, TemplateElement, and JSXText, and detects strings containing Japanese.
In addition to the four cases above, it also catches JSXText and Error messages.
<Button>保存</Button>
throw new Error(
'データの取得に失敗しました',
);
Logging uses such as console.error('データの取得に失敗しました') are excluded. Comments are not string literals in the AST, so they aren't targeted in the first place.
The 10,000+ candidates were mostly outside JSX
Applying it to the whole existing codebase found more than 10,000 candidates for hardcoded strings. I had thought the thousands fromjsx-no-literals were already a lot, and this added an order of magnitude. They were in various places: Pages, Features, Widgets, validation schemas, error messages, and Shared UI.
Particularly numerous were validation messages such as z.string().min(1, '入力してください'), and
const menu = {
label: '設定',
};
strings outside JSX like this. This is the part that lint rules looking only at JSX had missed.
Seeing these results, I decided on a policy. Rather than going in the direction of uniformly prohibiting all literals, I look at actual detection results and gradually exclude contexts that aren't translation targets.
Don't try to build a perfect rule from the start
Judging hardcoded strings is harder than I thought.throw new Error('予期しない状態です') is caught by the current rule, but whether it's displayed to the user can't be determined from the string alone. On the other hand, z.string().min(1, '名前を入力してください') is almost certainly user-facing.
Currently I grow the rule in the following cycle.
- Detect broadly
- Look at actual violations
- Exclude clear false positives
- Add them to the rule's test cases
- Apply to the entire codebase again
Rather than designing a general-purpose lint rule in my head, applying it in bulk to real code to raise its precision tends to produce a more practical rule. In the future, I'm thinking of turning it into a rule that detects user-facing hardcoded strings in general, not just Japanese, and extracting it as an Oxlint / ESLint-compatible Plugin.
Since there's a lot of existing code, tighten gradually
With 10,000 candidates, you can't make everything an error from the start. At present, I've madepnpm lint:i18n an independent command, and the normal pnpm lint runs as before. Once migration progresses, I plan to integrate i18n/no-hardcoded-japanese: error into the normal lint.
When introducing a new lint rule to an existing project, the wall is always that "there are too many existing violations to put it into CI." If generalizing it as a Plugin, I'd like to support a phased migration where existing violations are temporarily tolerated as a baseline and only new violations become CI errors.
In the Coding Agent era, make guardrails multi-layered
As a result, i18n quality assurance is now split into multiple layers.
Coding Agent / Developer
│
▼
┌─────────────────────────────┐
│ Project Rules │
│ │
│ Use useAppTranslation │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Oxlint │
│ │
│ Detect untranslated strings │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ TypeScript │
│ │
│ Translation keys │
│ Namespace │
│ Interpolation │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Vitest / CI │
│ │
│ Locale structure │
│ Interpolation consistency │
└─────────────────────────────┘
Even if you write "always use useAppTranslation" for the AI, it will get it wrong sometimes. That's why I layer instructions, API design, the type system, lint, and tests.
If the AI writes <Button>保存</Button>, lint:i18n catches it. If it writes t('crud.create.succes'), TypeScript fails it. If it writes t('crud.create.success') and forgets interpolation, it fails for a missing argument. If it writes 'Created {{resource}}' on the English side, the test fails. Whichever layer stops it, the AI can read the error and fix it.
Looked at another way, this is also narrowing the search space of code the AI can generate. If the translation API is t(key: string), a Coding Agent can write anything.
t('create.success');
t('createSuccess');
t('crud.create.success');
t('project.create.success');
All of them are valid TypeScript. If the type allows only correct keys, then even if it's wrong at first, the compiler gives feedback.
Generating API types from OpenAPI, defining input boundaries with Zod, restricting Error Codes with a Union, guaranteeing exhaustiveness with exhaustive switch. These follow the same idea, and I think they'll become more important going forward across development with Coding Agents.
How an actual form changed
The original code, as shown at the beginning, hardcodedmessage: 'プロジェクトを作成しました'. After i18n support, it looks like this.
const commonT = useAppTranslation('common');
const projectT = useAppTranslation('project');
const handleSubmit = async (data: ProjectFormData) => {
reset();
try {
await createProject(data);
showToast({
type: 'success',
message: commonT('crud.create.success', {
entity: projectT('name'),
}),
});
navigate('/projects');
} catch (err) {
console.error(err);
showToast({
type: 'error',
message: commonT('crud.create.failed', {
entity: projectT('name'),
}),
});
}
};
The amount of code has increased. In exchange, all three mistakes listed at the beginning are stopped by types in this form as well.
This design isn't necessary for every project
Some parts may look like overkill, and in fact I don't think this setup works everywhere. For small apps, apps whose supported languages will hardly increase, teams where non-engineers manage translations centered on a Translation Management System, or environments with many non-TypeScript clients, JSON and general translation management tools should be more suitable. Co-locating in FSD also doesn't suit uses where you want to review all translated text on a single screen. Our environment this time was the reverse of these, so this design fit.
Summary
At first it was just "add i18next to React and support multiple languages." As I went on, I ended up designing everything from where to put translation resources to the Adapter, interpolation types, FSD, lint, and tests. What I consistently emphasized was not hoping that correct code gets written, but creating a state where wrong code doesn't pass.
However, not all layers are effective yet. Types and tests are working, but lint, as mentioned, is run separately aslint:i18n, and I'm working through the 10,000 candidates. The Adapter boundary is also still protected by instructions.
These guardrails are effective when humans write code, too. But Coding Agents make large numbers of changes at high speed. That's why the value of constraining the freedom of changes through types, lint, tests, and API design has gone up compared with before. Rather than asking AI to "not make mistakes," the compiler or lint stops it when it does. This i18n work became a subject for laying the foundation for that.