Electron apps have a split personality. Half of your UI is a React app running in the renderer process. The other half is hidden in the application menu, the tray icon, and the context menus, which are built by native code in the main process. When you internationalize, you have to solve the same problem twice, in two different worlds that can't share a translation runtime.
Kiji privacy proxy is a local proxy that strips personally identifiable information out of your LLM requests before they leave your machine. It recently got French localization in PR #591, and the shape of that PR is a good map of the work involved. This post walks through the dependencies, implementation steps, and pitfalls.
Three packages do the actual work:
Package | Role |
| The translation engine. Key lookup, interpolation, pluralization, fallback chains. |
| React bindings — the |
| Reads the user's language from |
Two more are tooling, not runtime:
Package | Role |
| The |
(custom script) |
|
```bash
npm install i18next react-i18next i18next-browser-languagedetector
npm install -D eslint-plugin-i18next
```Note what is not on the list: nothing runs in the Electron main process. That's deliberate, and it's the first structural decision you have to make.
Create src/i18n/index.ts. It initializes i18next once, as a side effect, and you import it at the top of your entry point (index.js) before React mounts.
Kiji splits its strings into eight namespaces — common, settings, dashboard, activity, mappings, about, onboarding, modals — each backed by one JSON file per locale. Namespacing is not cosmetic: it prevents a 2,000-key common.json from becoming the file every PR touches, and it makes the parity check reportable per area.
Three config options are load-bearing:
load: "languageOnly" — normalizes fr-FR, fr-CA, fr-BE down to the fr base. Without this, a Québécois user gets English.
fallbackLng: "en" — a missing French key renders the English string, not the raw key.
caches: ["localStorage"] on the detector — this is your persistence layer. The language selector needs zero storage code of its own.
react: { useSuspense: false } — resources are bundled, not fetched, so there's nothing to suspend on.
This is the bulk of the diff and the least interesting part, right up until it isn't. Kiji's extraction ran across the playground, settings, dashboard, sidebar, activity log, mappings table, onboarding, about page, and modals. Four cases needed real thought:
Class components. ErrorBoundary has to be a class (React gives you no hook for componentDidCatch), so it can't call useTranslation(). Wrap it in the withTranslation() HOC and pull t from props.
Embedded markup. A sentence with a link in the middle of it must not be split into three keys — the word order changes between languages. Use <Trans>, which lets the translator move the tags around inside the string:
```json
{ "footer": "Built by <0>575 Lab</0> — <1>learn more</1>." }```
Pluralization. i18next handles _one / _other automatically from {{count}}. It does not handle it safely. See the gotcha section.
Shared formatters. Kiji has a logFormatters.ts module that builds display strings for log rows. It's not a component, so it can't call the hook — the translator is passed as an argument.
A LanguageSection in Settings that calls i18next.changeLanguage("fr"). That's the whole feature. The detector's localStorage cache writes the choice through on change and restores it on next launch.
Here's the part that only exists because this is Electron.
The application menu and the tray menu are constructed in the main process, which has no React, no useTranslation, and no reason to load i18next. Kiji's answer is a self-contained menu-i18n.js in the main process: a plain object of en/fr label tables plus a small normalize / select / lookup API with {{name}} interpolation. Every hardcoded label: in the menu builder routes through it.
The two halves stay in sync over IPC:
The renderer is the source of truth. On i18n init, and on every languageChanged event, it pushes its resolved base language to main over a new set-language channel.
Main persists the language into the app config and rebuilds both the application menu and the tray menu.
At startup, main seeds the menu from the persisted config, before the renderer has even loaded.
The push is guarded on window.electronAPI existing, so the browser build is unaffected.

Two guards, one hard and one soft.
npm run i18n:check runs scripts/check-i18n-parity.js in the lint job. Per namespace, it verifies:
Base-key parity — every key in en exists in fr and vice versa, with plural suffixes normalized away before comparison.
Plural completeness — each locale has every CLDR plural category it needs, not just the ones English happens to use.
Placeholder sets match — {{count}} in en means {{count}} in fr; a typo'd {{coutn}} renders literally.
<Trans> tags match and balance — <0> opened is <0> closed, and the same tag indices appear in both.
eslint-plugin-i18next's no-literal-string, in jsx-text-only mode, as a warning — it surfaces un-extracted UI text without gating CI. Technical and brand literals in attributes are deliberately out of scope; you do not want to translate aria-controls="tab-panel".
French has a many plural category, and i18next will not fall back to other. This is the bug the parity check caught. English CLDR has one / other. French CLDR has one / many / other — many fires at millions-scale counts. If fr/common.json defines entries_one and entries_other but not entries_many, then t("entries", { count: 2_000_000 }) renders the raw key string to the user. Not the English fallback. The key. Kiji had this bug across three namespaces before the checker was written.
Number formatting is part of localization. 1,051 ms in English is 1 051 ms in French (narrow no-break space, not comma). 79.0% avg is 79,0 % en moy. (decimal comma, and a space before the %). Kiji's fmt() helper takes the active language and delegates to Intl.NumberFormat. This is also why the dashboard passes pre-formatted numbers as plain interpolation variables rather than as i18next's count — you want your formatting, not i18next's.
Backend-supplied strings leak. The dashboard's KPI delta window arrives from the backend as a raw "7d" and was being interpolated verbatim. French wants 7 j. Localizing the unit suffix meant intercepting a value that never looked like UI copy.
Translations change your layout. French is reliably 15–20% longer than English. The donut chart's center label, entities, became renseignements détectés and overflowed the SVG. The fix wasn't a French special-case — it was making the Donut component render its center label only when non-empty, and setting the French string to "". Data-driven, works for the next locale too.
Terminology drift is real. Look at the commit log: PII → données personnelles → renseignements personnels, then a sweep to flip every adjective and pronoun that agreed with the old gender (détectées → détectés, elles → ils). Pick your glossary before you translate, not after.
Where does the preference live? The open review question on Kiji's PR: localStorage is exactly right for a single-user desktop app, but Kiji also ships a Linux server build where multiple users hit the same instance. User A picks French; the next user's browser has its own localStorage, so they get English — which is fine, actually. The harder question the reviewer raised is whether language should live in the shared app config instead, and whether non-admins should be allowed to change it at all. There's no universally right answer; there is a wrong one, which is not deciding.
Everything above, compressed into something you can paste into a fresh Electron + React app.
src/i18n/index.tsimport i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enCommon from "./locales/en/common.json";
import frCommon from "./locales/fr/common.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { common: enCommon },
fr: { common: frCommon },
},
ns: ["common"],
defaultNS: "common",
fallbackLng: "en",
load: "languageOnly", // fr-FR -> fr
supportedLngs: ["en", "fr"],
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"], // persistence, for free
},
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
// Push the resolved language to the Electron main process so it can
// rebuild the native menus. Guarded: the web build has no electronAPI.
const pushLanguage = (lng: string) =>
window.electronAPI?.setLanguage(lng.split("-")[0]);
pushLanguage(i18n.resolvedLanguage ?? "en");
i18n.on("languageChanged", pushLanguage);
export default i18n;src/i18n/locales/en/common.json{
"app": { "title": "Privacy Proxy" },
"status": "Listening on port {{port}}",
"masked_one": "{{count}} item masked",
"masked_other": "{{count}} items masked",
"footer": "Read the <0>docs</0> to get started.",
"language": { "label": "Language", "en": "English", "fr": "Français" }
}src/i18n/locales/fr/common.jsonNote masked_many. French CLDR requires it; i18next will not fall back to _other.
{
"app": { "title": "Proxy de confidentialité" },
"status": "À l'écoute sur le port {{port}}",
"masked_one": "{{count}} élément masqué",
"masked_many": "{{count}} d'éléments masqués",
"masked_other": "{{count}} éléments masqués",
"footer": "Consultez la <0>documentation</0> pour commencer.",
"language": { "label": "Langue", "en": "English", "fr": "Français" }
}src/App.tsximport { useTranslation, Trans } from "react-i18next";
export default function App() {
const { t, i18n } = useTranslation("common");
// Locale-aware number formatting — not i18next's job.
const fmt = (n: number) =>
new Intl.NumberFormat(i18n.resolvedLanguage).format(n);
return (
<main>
<h1>{t("app.title")}</h1>
<p>{t("status", { port: 8080 })}</p>
{/* count drives pluralization; the formatted value is separate */}
<p>{t("masked", { count: 2_000_000, formatted: fmt(2_000_000) })}</p>
{/* markup stays inside the translated string */}
<p>
<Trans i18nKey="footer" ns="common">
Read the <a href="https://example.com">docs</a> to get started.
</Trans>
</p>
<label>
{t("language.label")}{" "}
<select
value={i18n.resolvedLanguage}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
<option value="en">{t("language.en")}</option>
<option value="fr">{t("language.fr")}</option>
</select>
</label>
</main>
);
}src/electron/menu-i18n.js (main process)No i18next here. Just a table and a lookup.
const TABLES = {
en: { "menu.file": "File", "menu.quit": "Quit {{name}}" },
fr: { "menu.file": "Fichier", "menu.quit": "Quitter {{name}}" },
};
const normalize = (lng) => {
const base = String(lng || "en").split("-")[0].toLowerCase();
return TABLES[base] ? base : "en";
};
function t(lng, key, vars = {}) {
const table = TABLES[normalize(lng)];
const raw = table[key] ?? TABLES.en[key] ?? key;
return raw.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
}
module.exports = { t, normalize };src/electron/electron-main.js (main process, wiring)const { app, Menu, ipcMain } = require("electron");
const { t } = require("./menu-i18n");
const config = require("./config"); // your persisted store
function buildMenu(lng) {
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{
label: t(lng, "menu.file"),
submenu: [
{ label: t(lng, "menu.quit", { name: "Kiji" }), role: "quit" },
],
},
])
);
// rebuild the tray context menu here too
}
app.whenReady().then(() => buildMenu(config.get("language", "en"))); // seed
ipcMain.handle("set-language", (_e, lng) => {
config.set("language", lng);
buildMenu(lng);
});electron-preload.jsThe preload script exposes it:
contextBridge.exposeInMainWorld("electronAPI", {
setLanguage: (lng) => ipcRenderer.invoke("set-language", lng),
});en — the default.

fr — after selecting Français. The menu bar changed too, because the renderer pushed set-language over IPC and the main process rebuilt it.

Three things worth staring at in that second box. The link moved — it's mid-sentence in English and later in French — and it moved because <Trans> let the translator carry <0>…</0> with the noun instead of splitting the sentence into fragments. The number is grouped with a space, not a comma, because Intl.NumberFormat was handed the active locale. And the plural is masked_many, which is a category English does not have and which, had it been missing, would have rendered the literal string masked_many to a French user looking at two million masked items.
The parity check exists to catch exactly that last one before a reviewer has to.
Internationalizing an Electron app is three problems wearing a trench coat:
Extracting strings from React, extracting labels from a process that can't see React, and keeping the two catalogs honest as they drift apart. Kiji's PR solves them with react-i18next, a hand-rolled string table behind an IPC channel, and a parity script in CI.
The parity script is the piece people skip and shouldn't. Structural correctness — keys match, placeholders match, plural categories are complete — is mechanically checkable, and will otherwise be discovered by a user.
Translation quality is not checkable, which is why Kiji's French shipped machine-drafted and explicitly flagged as pending native-speaker review. Know which of the two you've actually guaranteed.
Tags