Internationalization

August 18, 2026 · View on GitHub

i18next + react-i18next

Out of the box. i18next@26.3.6, react-i18next@17.0.11.

i18next is the de-facto JS i18n core — resources, interpolation, plurals, namespaces, backends — and react-i18next binds it to React with useTranslation, <Trans> for translations containing markup, and provider-driven re-render on language change.

The core has no DOM in it and react-i18next renders whatever elements you hand it, so the whole stack runs unmodified: interpolation, count-based plurals, <Trans>, and i18n.changeLanguage('de') re-rendering the mounted tree in place. Node ships full ICU, so Intl.NumberFormat and Intl.DateTimeFormat formatting work too.

Two react-x11-specific rules:

  1. <Trans> component maps must use react-x11 elements. components={{ 1: <span/> }} throws react-x11: <span> is not allowed inside <text> — inside a <text>, only strings and nested <text> spans are legal. Use <text style={...}/> as the wrapper.
  2. Turn off HTML escaping. i18next escapes interpolated values for an HTML sink by default, and react-x11 draws raw glyphs, so O'Brien renders as O&#39;Brien. Set interpolation: { escapeValue: false }.
import React from 'react';
import i18next from 'i18next';
import {
  I18nextProvider,
  initReactI18next,
  useTranslation,
  Trans,
} from 'react-i18next';

const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
  lng: 'en',
  resources: {
    en: {
      translation: {
        greeting: 'Hello, {{name}}!',
        items_one: '{{count}} item',
        items_other: '{{count}} items',
        rich: 'Press <1>Enter</1> to save',
      },
    },
  },
  interpolation: { escapeValue: false }, // no HTML sink to escape for
});

function Status() {
  const { t } = useTranslation();
  return (
    <box style={{ flexDirection: 'column' }}>
      <text>{t('greeting', { name: 'X11' })}</text>
      <text>{t('items', { count: 2 })}</text>
      <text>
        <Trans
          i18nKey="rich"
          components={{ 1: <text style={{ color: '#e00' }} /> }}
        />
      </text>
    </box>
  );
}

// mount under <I18nextProvider i18n={i18n}> inside your <window>;
// i18n.changeLanguage('de') re-renders live

For loading translations from disk, i18next-fs-backend is the natural desktop choice; i18next-http-backend also works, since Node has fetch.

What is missing below the string

These are react-x11 gaps rather than i18next ones, and they matter more than the library choice:

  • There is no X input method. Dead keys and the Compose key are handled in the renderer (events.md), but there is no XIM/IBus integration, so a language that needs a real input method — CJK — can be displayed but not typed.
  • <Trans> with the default <strong>- and <em>-style numbered tags in translations is fine as long as every referenced component is a <text> span. An accidental DOM tag fails loudly at render, which is the good case.