Most Electron apps are built in one language, and the first internationalization pass usually means dropping a few translated strings into the renderer. That works until you ship to a non-English user: the menu bar and native dialogs stay English, dates and currency render in the wrong format, plurals break for languages with more than two forms, and a right-to-left locale leaves the layout mirrored only halfway. Desktop i18n is broader than web i18n because an Electron app owns the shell — the main process, the tray, the application menu, native dialogs, and every renderer window — and each surface reads its own strings. This guide covers how to detect the system language, split catalogs between main and renderer, format messages and locale-aware data correctly, handle RTL, package translations without bloating the installer, switch language at runtime, persist a user override, and test that your translations actually render.
TL;DR
- Locale lives in two places. The main process owns menus, tray labels, dialogs, and error messages; renderers own their UI. Both read from the same fallback chain, but they load catalogs separately.
-
Detect the system language, don't assume English. Use
app.getLocale()andapp.getPreferredLanguages(), then map the raw locale to a supported catalog with a sensible fallback. - Never concatenate translated strings. Use ICU-style messages with interpolation and plural/select clauses; word order and plural rules differ across languages.
-
Format dates, numbers, and currency with
Intl. Never hand-roll month names or decimal separators; pass the locale and timezone toIntl.DateTimeFormat,NumberFormat, andRelativeTimeFormat. -
Set text direction on the root element. For RTL locales, toggle
dir="rtl"and prefer CSS logical properties so the layout mirrors cleanly. -
Package translations as data, not code. Keep catalogs under
extraResourcesor unpacked fromasar, load them on demand, and version them with the app so a stale catalog can't ship. - Runtime switching means rebuilding shell strings. The renderer re-renders from a new catalog; the main process rebuilds the menu and refreshes tray labels. Persist the user's explicit choice and fall back to the system locale otherwise.
- The two traps that cost real time: (1) hard-coded English in the main process that no renderer translation can reach; (2) assuming one plural form fits every language.
Table of contents
- Why desktop i18n differs from web i18n
- Where locale lives: main vs renderer
- Detecting the system language
- Message catalogs and interpolation
- Pluralization and select clauses
- Dates, numbers, currency, and timezone
- RTL layouts and text direction
- Packaging locale resources
- Runtime language switching
- Persisting a user override and fallback chain
- Testing localization with a pseudo-locale
- Common pitfalls
- Checklist
Why desktop i18n differs from web i18n
A website usually renders inside one browser tab, and the browser already handles the OS chrome. An Electron app draws its own shell: the application menu, tray icon tooltip, context menus, native file dialogs, notifications, and every BrowserWindow. Each of these surfaces can show text, and each reads strings independently.
| Surface | Where strings live | Typical mistake |
|---|---|---|
| Application menu | Main process (Menu) |
Menu hard-coded in English while the UI is translated |
| Tray tooltip / menu | Main process (Tray) |
Tooltip set once at startup, never refreshed on switch |
| Native dialogs | Main process (dialog) |
Buttons and titles passed as English literals |
| Renderer windows | Renderer catalog | Strings built by concatenation instead of message templates |
| Notifications | Main or renderer via IPC | Body text formatted before the locale is known |
The practical consequence: i18n is a cross-process concern. A translation system that only covers the renderer leaves a visibly English shell around a translated UI, which reads as unfinished.
Where locale lives: main vs renderer
Split the catalog by process, but keep one source of truth for the active locale.
| Layer | Responsibility | Reads |
|---|---|---|
| Main process | Menu, tray, dialogs, notifications, error toasts | Main catalog; receives active locale from settings |
| Preload bridge | Exposes t(), locale, and direction to the renderer |
Passes main-provided locale and renderer catalog |
| Renderer | Window UI, headings, buttons, empty states | Renderer catalog; re-renders when locale changes |
The main process decides the active locale (system default or stored override) and hands it to renderers over the preload bridge. Renderers should not call OS APIs to detect locale themselves; they render whatever locale the main process resolved.
Detecting the system language
Electron exposes several locale signals. app.getLocale() returns the OS locale as BCP 47 (for example fr-FR); app.getPreferredLanguages() returns the ordered list of languages the user prefers, which is more useful when the OS locale and the user's preferred language differ.
// main/locale.ts
import { app } from "electron";
const SUPPORTED = ["en", "fr", "de", "ar", "zh-CN"] as const;
type SupportedLocale = (typeof SUPPORTED)[number];
function normalize(raw: string): string {
return raw.replace("_", "-");
}
export function resolveLocale(storedOverride?: string): SupportedLocale {
const candidates = storedOverride
? [storedOverride, ...app.getPreferredLanguages(), app.getLocale()]
: [...app.getPreferredLanguages(), app.getLocale()];
for (const candidate of candidates.map(normalize)) {
const exact = SUPPORTED.find((l) => l === candidate);
if (exact) return exact;
const lang = candidate.split("-")[0];
const regional = SUPPORTED.find((l) => l.split("-")[0] === lang);
if (regional) return regional;
}
return "en"; // fallback
}
Match first on the full regional tag (zh-CN), then on the language subtag (zh), and finally fall back to a default you actually ship. Never fall back to a locale you don't bundle — that produces an empty catalog at runtime.
Message catalogs and interpolation
Keep translations as structured data keyed by stable message IDs. A catalog is a flat or nested object; the t() function looks up the ID and fills placeholders.
// resources/locales/en/renderer.json
{
"common.cancel": "Cancel",
"project.open.recent": "Open recent project",
"project.deleted": "Project {name} was deleted",
"window.untitled": "Untitled"
}
// shared/messages.ts
type Catalog = Record<string, string>;
export function makeTranslator(catalog: Catalog, fallback: Catalog) {
return function t(id: string, vars: Record<string, string | number> = {}): string {
const template = catalog[id] ?? fallback[id] ?? id;
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
return value === undefined ? `{${key}}` : String(value);
});
};
}
Two rules matter here:
- IDs are stable, values are translatable. Never reuse an ID for a different sentence; translators work from the key, and changing the meaning silently breaks existing translations.
-
Interpolate values, not sentence parts.
"Deleted {name}"lets a translator reorder the placeholder;"Deleted " + name + " files"does not.
For production apps, i18next with react-i18next or vue-i18n handles catalog loading, fallback, and pluralization out of the box; the principles above map directly onto their configuration.
Pluralization and select clauses
English has two plural forms (one, other), but many languages have three or more, and some have special rules for zero, fractions, or ordinal numbers. Concatenating "You have " + count + " message" breaks the moment the grammar changes.
Use a plural-aware message. A minimal ICU-style resolver:
// shared/plural.ts
type PluralRule = { one?: string; other: string; zero?: string; few?: string; many?: string };
export function plural(rule: PluralRule, count: number): string {
if (count === 0 && rule.zero) return rule.zero;
if (count === 1 && rule.one) return rule.one;
return rule.other;
}
{
"inbox.unread": {
"zero": "No unread messages",
"one": "One unread message",
"other": "{count} unread messages"
}
}
Select clauses handle gender or category differences the same way: pick the template by a category (male / female / other) instead of stitching words together. Real ICU MessageFormat libraries compute the correct plural category per locale, which matters for languages like Arabic, Russian, or Polish where the form depends on the exact number, not just whether it is one.
Dates, numbers, currency, and timezone
Format locale-aware data with the Intl APIs built into V8. These already know each locale's calendar, month names, digit grouping, decimal separator, and currency rules.
// shared/format.ts
export function formatDate(locale: string, date: Date, timezone?: string): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: timezone,
}).format(date);
}
export function formatCurrency(locale: string, amount: number, currency: string): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amount);
}
export function formatRelative(locale: string, diffMs: number): string {
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
Math.round(diffMs / 60000),
"minute",
);
}
| Concern | Use | Avoid |
|---|---|---|
| Date and time | Intl.DateTimeFormat |
Hard-coded month arrays, date.toString()
|
| Number / currency | Intl.NumberFormat |
Manual decimal points or thousand separators |
| Relative time | Intl.RelativeTimeFormat |
Hand-written "x minutes ago" strings |
| Timezone | Store UTC, format to the user's zone | Storing local wall-clock time |
Store timestamps in UTC internally and format them to the user's timezone at display time. A currency amount should keep its currency code separate from the number; never convert or assume a locale implies a currency.
RTL layouts and text direction
For right-to-left locales such as Arabic or Hebrew, set the direction on the document root and let the layout mirror itself. Don't mirror the UI by hand with absolute pixel flips.
// renderer: set direction from the resolved locale
const RTL_LOCALES = ["ar", "he", "fa", "ur"];
export function directionFor(locale: string): "rtl" | "ltr" {
return RTL_LOCALES.includes(locale.split("-")[0]) ? "rtl" : "ltr";
}
document.documentElement.setAttribute("dir", directionFor(locale));
document.documentElement.setAttribute("lang", locale);
Prefer CSS logical properties so spacing and positioning follow the direction automatically:
.card {
margin-inline-start: 12px; /* instead of margin-left */
padding-inline-end: 16px; /* instead of padding-right */
text-align: start;
}
A few things stay LTR even in an RTL UI: code snippets, URLs, email addresses, and numeric sequences. Mark those with dir="ltr" and an explicit lang so they don't reorder. Icons that point left or right should flip with transform: scaleX(-1) under RTL rather than being swapped manually in the markup.
Packaging locale resources
Translations are data and should ship separately from application code so a catalog can be updated without touching the bundled JavaScript.
| Option | Behavior | When to use |
|---|---|---|
extraResources/locales |
Copied as files next to the app; loaded by path | Multiple large catalogs; load on demand |
asarUnpack |
Catalogs extracted from asar so they can be read at runtime |
Catalogs kept inside the app archive but must be readable |
| Bundled in renderer JS | Catalogs imported at build time | A small number of languages; simplest setup |
// electron-builder.json (excerpt)
{
"extraResources": [
{ "from": "resources/locales", "to": "locales" }
],
"asarUnpack": ["resources/locales/**"]
}
Load the active catalog lazily at startup, not all catalogs at once. Bundle a version marker alongside the catalogs so a build mismatch (for example, a renderer expecting message IDs that the shipped catalog doesn't have) degrades to the fallback locale instead of rendering raw IDs.
Runtime language switching
Users expect to switch language without restarting the app. Switching has two halves: the renderer re-renders from a new catalog, and the main process rebuilds its shell strings.
// main: rebuild the menu and tray when locale changes
import { Menu, Tray } from "electron";
export function applyShellLocale(locale: string, tray: Tray | null) {
const catalog = loadMainCatalog(locale);
Menu.setApplicationMenu(buildMenu(catalog));
if (tray) tray.setContextMenu(buildTrayMenu(catalog));
tray?.setToolTip(catalog["tray.tooltip"]);
}
// renderer: react to a locale change pushed over the bridge
window.electronI18n.onLocaleChanged(({ locale, catalog }) => {
document.documentElement.setAttribute("dir", directionFor(locale));
document.documentElement.setAttribute("lang", locale);
render(catalog); // rebuild the UI from the new catalog
});
Push the change from the main process to every open window. Persisted user choices apply on next launch; a switch in the running session updates open surfaces immediately. Any state that holds formatted strings (cached dates, labels in memory) should be recomputed after the switch, not retained from the old locale.
Persisting a user override and fallback chain
The resolution order matters: an explicit user choice wins, then the system locale, then the built-in fallback.
// main: resolve with stored override
import Store from "electron-store";
const store = new Store<{ locale?: string }>();
const activeLocale = resolveLocale(store.get("locale"));
function setLocale(locale: string) {
store.set("locale", locale);
applyShellLocale(locale, tray);
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("i18n:changed", {
locale,
catalog: loadRendererCatalog(locale),
});
}
}
If the user clears their override, fall back to the system locale again rather than keeping the last choice frozen. Keep the override stored as a locale tag, and validate it against the supported list before applying it.
Testing localization with a pseudo-locale
Before real translators arrive, run a pseudo-localization pass to catch hard-coded strings. A pseudo-locale wraps every message in brackets, expands the length, and marks untranslated text:
// shared/pseudo.ts
export function pseudo(template: string): string {
return `[!! ${template} !!!]`.replace(/\s/g, "Ä“");
}
Enable it behind a dev flag. During the pass, look for:
- English text that never appears in brackets, meaning it was hard-coded in the DOM or main process.
- Layouts that break when strings expand — some languages render 20–30% longer than English.
- Concatenated messages where placeholders land in the wrong position.
- Dates, numbers, or currency that don't change with the locale.
Then spot-check one RTL locale to confirm the layout mirrors, icons flip, and embedded code stays LTR.
Common pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| English strings in the main process | Menu/tray/dialogs stay untranslated no matter the renderer catalog | Load a main catalog and rebuild shell strings on switch |
| String concatenation for plurals | Breaks languages with >2 plural forms or different word order | Use ICU-style plural/select messages |
| Hand-formatted dates and numbers | Wrong month names, separators, currency | Use Intl.DateTimeFormat / NumberFormat
|
Hard-coded margin-left / right
|
RTL layout doesn't mirror cleanly | Use logical properties (margin-inline-*) |
| Loading every catalog at startup | Larger memory and startup cost | Load the active catalog lazily |
| Raw message IDs showing up | Missing catalog entry falls through to the key | Version catalogs and fall back to default locale |
| Keeping cached formatted strings after a switch | Old-locale labels linger in memory | Recompute derived strings on locale change |
| Storing local wall-clock times | Cross-zone and locale formatting breaks | Store UTC, format at display time |
Checklist
- [ ] Main and renderer both resolve locale from one fallback chain (override → system → default).
- [ ] System locale is detected via
app.getPreferredLanguages()/app.getLocale(), not assumed to be English. - [ ] All UI text comes through message IDs; no hard-coded literals in renderer or main process.
- [ ] Messages use interpolation; plurals and gender/select cases are handled with an ICU-style resolver.
- [ ] Dates, numbers, currency, and relative time use
Intl; timestamps are stored in UTC. - [ ] RTL locales set
dirandlangon the root; layout uses logical properties; embedded code stays LTR. - [ ] Catalogs are packaged as data, loaded lazily, and versioned with the app build.
- [ ] Runtime switching rebuilds the menu/tray, refreshes every open window, and recomputes cached strings.
- [ ] Explicit user override is persisted and validated; clearing it returns to the system locale.
- [ ] A pseudo-locale dev pass has caught hard-coded strings and expansion/overflow issues.
Top comments (0)