We decided to add multilingual support to a web application built with React.

Before starting, I assumed it would be a trivial job. Take the Japanese written directly in components,

showToast({
  type: 'success',
  message: 'プロジェクトを作成しました',
});
replace it with t() like this, and be done.
showToast({
  type: 'success',
  message: t('project.createSuccess'),
});
Once I actually got my hands dirty, it was a different story. Problems that mere replacement wouldn't solve kept turning up. First, as long as the argument of t() is a string, nobody stops a typo in a translation key.
t('field.naem');
Next, there's the forgotten interpolation argument. Even if a translation requires {{entity}}, you can call it with no arguments at all. Nobody notices until {{entity}}を作成しました shows up on screen, proudly and in full view.
t('crud.create.success');

On top of that, interpolation variable names can 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. An AI rewrites dozens of files in one go. Expecting it to behave itself and get every single spot right is, frankly, a little too optimistic. It works far better to create a state where wrong code doesn't pass in the first place.

So along with introducing i18n, I designed the machinery around it. Translation resources are managed in TypeScript, and their structure is validated across languages. i18next-specific APIs are kept away from the application, and translation keys and interpolation arguments are made type-safe. Inconsistencies are stopped by tests and CI, and untranslated hardcoded strings are caught by lint.

To give a sense of scale up front: when I ran my own lint rule against the existing code, there were more than 10,000 candidates for untranslated strings. I'll come back to that number later.

As a premise, this app is centered on React / TypeScript, and developers manage the translation resources themselves. Where non-engineers manage translations, the design below probably won't fit as is. I'll touch on that again at the end.

Translation Resources Written in TypeScript

To begin with, managing translation resources in JSON is the common setup. With i18next's CustomTypeOptions, JSON resources get types too. However, values imported from JSON widen to string, so they're no use for extracting interpolation variables, which I'll get to later. This time I wanted the translation resources firmly on the 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 crux is the as const at the end. Thanks to it, '{{entity}}を作成しました' remains a string literal type instead of string. Translation key type generation, IDE completion, and interpolation variable extraction all sit on top of that type information.

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 itself as the key, but I didn't adopt it.

t('プロジェクトを作成しました');

With this approach, merely rephrasing the wording changes the key as well. Say you turn "プロジェクトを作成しました" into "プロジェクトの作成が完了しました". That alone means rewriting every call site. I want a translation key to be an ID that identifies meaning, not display text.

Japanese as the Source of Structure

The next worry was keys going missing across languages. If I add field.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 looks like it would do. The catch is that it also demands the value name: 'プロジェクト' match. Telling the English resource to say "プロジェクト" is a bit much to ask. 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>;
Try forgetting description,
export const projectEn = {
  name: 'Project',

  field: {
    name: 'Project name',
  },
} as const satisfies TranslationOf<typeof projectJa>;

and TypeScript duly raises an error. Adding an extra key only to English is an error as well. In other words, the Japanese resource becomes the source of truth for translation structure, and the other languages follow its shape. An original and its copies: that is the relationship established here.

A Thin Adapter in Front of i18next

Granted, i18next has a Selector API, and TypeScript detects nonexistent keys with it.

const { t } = useTranslation('project');

t(($) => $.field.name);
// ✅ OK

t(($) => $.field.naem);
// ❌ Type Error
I could have used that as is. This time, though, application code doesn't call react-i18next's useTranslation directly. Instead, a project-standard API sits in between. Its argument is i18next's Namespace, in units like common and project.
const t = useAppTranslation('project');

t('field.name');

There are two reasons. One, I didn't want the dependency on i18next to spread across the whole application. Two, I wanted to stop "omitting interpolation arguments," which the Selector API doesn't stop. The latter is covered in the next section.

Structurally, it's a four-layer stack. The application calls useAppTranslation(), and the internal i18n Adapter delegates to react-i18next. Underneath that, i18next runs. That said, useAppTranslation isn't reimplementing a translation engine. Fallback, plurals, interpolation, and resource management are all left to i18next. What I 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." I should confess, though, that this one boundary is currently protected by instructions alone. There is no layer yet that mechanically stops violations.

Interpolation Arguments You Can't Skip

I tried it with commonJa.crud.create.success from earlier, that is, '{{entity}}を作成しました'. Using the Selector API with i18next 26.4.2 on hand, here is what I got.
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. Meanwhile, the first call, which omits the options argument, sails right through. That's because options is optional in the type definition.

Pass it and the contents get checked; don't pass it and nobody scolds you. For a general-purpose library, that's presumably intentional flexibility. In this application, however, I wanted to reliably stop cases where the argument is forgotten for a key that needs interpolation.

So for keys that require interpolation, I made the second argument itself mandatory. Since the translation resources are as const, entity can be pulled out 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 required 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 stopped too, and only correct calls get through.
t('crud.create.success', {
  resource: 'Project',
});
// ❌ Type Error

t('crud.create.success', {
  entity: 'Project',
});
// ✅ OK
With that, the Adapter guarantees three things: the key exists, the interpolation variable names are right, and the interpolation arguments are present. Honestly, I also have a personal preference that t('foo.bar') reads better than t(($) => $.foo.bar). But the main goal was always to confine the direct dependency on i18next while giving the application side a stricter contract.

Wording to Share, Wording Not to Share

As i18n progresses, similar messages pop up in droves. "プロジェクトを作成しました", "ユーザーを作成しました", "チームを作成しました". Define these per Entity as project.message.createSuccess, user.message.createSuccess, and a mountain of duplicates rises in no time. So I consolidated mechanical CRUD wording into the commonJa.crud shown earlier.

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, the word order can change, as in '{{entity}} was created successfully'. String concatenation can't cope with that difference in word order. So this always goes through interpolation. On the other hand, wording like "アーカイブ済みのプロジェクトは編集できません" doesn't go into common. It contains a domain rule: an archived Project can't be edited. Things like this live on the Entity side, like projectJa.message.cannotEditArchived above. The criterion is simple. Mechanical wording that holds just by swapping the Entity name goes in common. The moment business knowledge creeps in, it belongs to the Entity or Feature side.

Translations Placed in Slices

If I delete the Entity Project, I want its translations to disappear with it. Since this project uses Feature-Sliced Design (FSD), I didn't gather translation resources in locales/. Instead, they live in the directory of the functional unit that uses the wording, a Slice in FSD terms. Shared wording lives in src/shared/i18n/common/ as ja.ts and en.ts. Project's wording lives in src/entities/project/i18n/ as ja.ts, en.ts, and index.ts. The idea is not to treat translations as residents of a separate world.

What I Don't Do with Types

That said, I'm not trying to solve everything with types. Consider 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. Stopping this with types would mean adding interpolation variable comparison to TranslationOf. I'd rather not make the types that elaborate, thank you. Instead, whether interpolation variables match across languages is checked in Vitest. If the Japanese side expects entity and the English side expects resource, the test fails. What can be judged from a single resource goes to TypeScript; what can only be known by comparing several resources goes to tests. That division is far easier to maintain than piling on ever more complex types.

Hardcoded Strings That Slip Past the Types

Yet even with types locked down this far, TypeScript says nothing if a developer doesn't use t() in the first place.
<Button>保存</Button>

With a Coding Agent, all the more so. Even if it's written in the rules, one spot or so quietly slips through while dozens of files are being changed at once. So I wondered whether hardcoded user-facing strings could be caught with lint.

The first thing I tried was react/jsx-no-literals from Oxlint (a Rust-based linter that reimplements the main ESLint rules compatibly). Running it on the existing codebase produced thousands of violations. On inspection, though, 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 culprit.

Conversely, the cases I actually wanted to catch walked right past it.

{'保存しました'}
<Input placeholder="名前を入力してください" />
toast({
  message: '保存しました',
});
z.string().min(
  1,
  '名前を入力してください',
);

"Is there a literal inside JSX?" and "Is there an untranslated user-facing string?" sound similar, but they turned out to be entirely different questions.

So I cobbled together my own rule, i18n/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 aren't string literals in the AST, so they aren't targeted in the first place.

10,000 Candidates and a Rule That Grows

Running this rule across the whole existing codebase turned up more than 10,000 candidate hardcoded strings. I had thought the thousands from jsx-no-literals were already a lot, and now there was an extra digit. It's the kind of number that makes you want to quietly close the terminal and pretend you never saw it. The locations varied, too: Pages, Features, Widgets, validation schemas, error messages, and Shared UI. Especially numerous were validation messages like z.string().min(1, '入力してください'), and
const menu = {
  label: '設定',
};

strings sitting outside JSX like this. That was precisely the part that lint rules looking only at JSX had missed.

Seeing these results, I settled on a policy. I won't ban all literals across the board. Instead, I look at actual detection results and gradually exclude contexts that aren't translation targets.

After all, judging hardcoded strings is harder than I'd thought. throw new Error('予期しない状態です') is caught by the current rule. Whether it's ever shown to a user, however, can't be determined from the string alone. z.string().min(1, '名前を入力してください'), by contrast, is almost certainly user-facing.

So I'm not trying to build a perfect rule from the start. I detect broadly and look at the actual violations. I exclude clear false positives, add them to the rule's test cases, then run it across the whole codebase again. That cycle is how the rule grows.

A general-purpose lint rule designed purely in my head tends to stay a pipe dream. Hitting real code with it in bulk and raising its precision produces something far more practical. Down the line, I'd like it to detect user-facing hardcoded strings in general, not just Japanese. I'm also considering extracting it as an Oxlint / ESLint-compatible Plugin.

Of course, with 10,000 candidates, you can't make everything an error from day one. For now, pnpm lint:i18n is a separate command, and the regular pnpm lint runs as before. Once migration progresses, I plan to fold i18n/no-hardcoded-japanese: error into the regular lint.

Whenever a new lint rule goes into an existing project, the same wall comes up: "too many existing violations to put it in CI." If I generalize it as a Plugin, I'd like existing violations to be temporarily tolerated as a baseline. On top of that, it ought to support a phased migration where only new violations become CI errors.

Layered Guardrails

As a result, i18n quality assurance is now split across several layers. The outermost is the project rule "use useAppTranslation," and inside that, Oxlint catches untranslated strings. TypeScript then checks translation keys, Namespaces, and interpolation arguments. Finally, Vitest and CI verify the locale structure and the consistency of interpolation variables. Write "always use useAppTranslation" for the AI, and it will still get it wrong sometimes. That's exactly 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 rejects 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, this time the test goes red. Whichever layer stops it, the AI can read the error and fix it on its own. Seen another way, this narrows the search space of code the AI can generate. If the translation API is t(key: string), a Coding Agent can write just about anything.
t('create.success');
t('createSuccess');
t('crud.create.success');
t('project.create.success');

Every one of these is valid TypeScript. If the type admits only correct keys, then even when the agent gets it wrong at first, the compiler hands back feedback.

Generate API types from OpenAPI. Define input boundaries with Zod. Restrict Error Codes with a Union, and guarantee exhaustiveness with an exhaustive switch. All of these follow the same idea, and they'll only matter more across development with Coding Agents.

The Form, Rewritten

It's worth seeing how an actual form changed. The original code, as shown at the start, hardcoded message: 'プロジェクトを作成しました'. After i18n, 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'),
      }),
    });
  }
};

There's definitely more code. In exchange, all three mistakes listed at the start are stopped by types in this form as well.

Naturally, some of this may look like overkill, and I don't believe this setup works everywhere. For small apps, or apps whose supported languages will hardly grow, JSON and conventional translation management tools suit better. The same likely goes for teams where non-engineers manage translations around a Translation Management System, or environments with many non-TypeScript clients.

Co-locating in FSD also doesn't suit cases where you want to survey every translated string on a single screen. Our environment happened to be the exact reverse of all that. That is precisely why this design fit so nicely.

So that's the story of how simply adding i18next to React turned into designing everything from where translation resources live to the Adapter, interpolation types, FSD, lint, and tests. What I consistently emphasized was not hoping correct code gets written, but creating a state where wrong code doesn't pass.

Not every layer is pulling its weight yet, mind you. Types and tests are working. Lint, on the other hand, still runs separately as lint:i18n while I chip away at the 10,000 candidates. The Adapter boundary, too, is still held in place by instructions.

These guardrails help 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 change through types, lint, tests, and API design has clearly gone up. Rather than asking an AI to "please not make mistakes," it is far more reassuring to have a compiler that stops it when it does.