Chrome Extension Advanced I18n — Best Practices

15 min read

Advanced Internationalization Patterns

Overview

The basic i18n guide covers chrome.i18n fundamentals. This article tackles real-world patterns: dynamic locale switching, pluralization, RTL support, formatted dates/numbers, and type-safe message keys.


Pattern 1: Type-Safe Message Keys

Prevent typos by generating TypeScript types from your messages.json:

// scripts/generate-i18n-types.ts
import fs from "fs";
import path from "path";

const messages = JSON.parse(
  fs.readFileSync(
    path.resolve("_locales/en/messages.json"),
    "utf-8"
  )
);

const keys = Object.keys(messages);
const union = keys.map((k) => `  | "${k}"`).join("\n");

const output = `// Auto-generated — do not edit
export type MessageKey =
${union};

export function getMessage(key: MessageKey, substitutions?: string[]): string {
  return chrome.i18n.getMessage(key, substitutions);
}
`;

fs.writeFileSync("src/i18n.generated.ts", output);
console.log(`Generated ${keys.length} message keys`);

Usage:

import { getMessage } from "./i18n.generated";

// Autocomplete and compile-time checking
const name = getMessage("extensionName"); // OK
const bad = getMessage("typoHere");       // TS error

Add to your build:

{
  "scripts": {
    "i18n:types": "ts-node scripts/generate-i18n-types.ts",
    "build": "npm run i18n:types && vite build"
  }
}

Pattern 2: Pluralization

Chrome’s i18n has no built-in pluralization. Implement it with ICU-style patterns:

// i18n/plural.ts
type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";

const PLURAL_RULES: Record<string, Intl.PluralRules> = {};

function getPluralRules(locale?: string): Intl.PluralRules {
  const lang = locale ?? chrome.i18n.getUILanguage();
  if (!PLURAL_RULES[lang]) {
    PLURAL_RULES[lang] = new Intl.PluralRules(lang);
  }
  return PLURAL_RULES[lang];
}

interface PluralMessages {
  zero?: string;
  one: string;
  two?: string;
  few?: string;
  many?: string;
  other: string;
}

export function plural(count: number, messages: PluralMessages): string {
  const category = getPluralRules().select(count) as PluralCategory;
  const template = messages[category] ?? messages.other;
  return template.replace("{count}", String(count));
}

Usage:

import { plural } from "./i18n/plural";

// English: "1 tab" / "5 tabs"
const msg = plural(tabCount, {
  one: "{count} tab",
  other: "{count} tabs",
});

// Russian: proper declensions
// one: "{count} вкладка"
// few: "{count} вкладки"
// many: "{count} вкладок"
// other: "{count} вкладок"

Pattern 3: Dynamic Locale Switching

Chrome extensions use the browser’s locale by default. To let users choose their own locale:

// i18n/dynamic-locale.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";

const schema = defineSchema({
  userLocale: "" as string, // empty = use browser default
});

const storage = createStorage({ schema, area: "sync" });

// Load all message bundles at build time
const bundles: Record<string, Record<string, { message: string }>> = {
  en: require("../../_locales/en/messages.json"),
  es: require("../../_locales/es/messages.json"),
  fr: require("../../_locales/fr/messages.json"),
  ja: require("../../_locales/ja/messages.json"),
};

let currentLocale = chrome.i18n.getUILanguage().split("-")[0];
let currentBundle = bundles[currentLocale] ?? bundles.en;

export async function initLocale(): Promise<void> {
  const userLocale = await storage.get("userLocale");
  if (userLocale && bundles[userLocale]) {
    currentLocale = userLocale;
    currentBundle = bundles[userLocale];
  }
}

export function t(key: string, substitutions?: string[]): string {
  const entry = currentBundle[key];
  if (!entry) return chrome.i18n.getMessage(key, substitutions) || key;

  let msg = entry.message;
  if (substitutions) {
    substitutions.forEach((sub, i) => {
      msg = msg.replace(`$${i + 1}`, sub);
    });
  }
  return msg;
}

export async function setLocale(locale: string): Promise<void> {
  if (!bundles[locale]) throw new Error(`Unknown locale: ${locale}`);
  await storage.set("userLocale", locale);
  currentLocale = locale;
  currentBundle = bundles[locale];
}

export function getLocale(): string {
  return currentLocale;
}

Pattern 4: RTL (Right-to-Left) Support

Extensions must handle RTL languages like Arabic, Hebrew, and Persian:

// i18n/rtl.ts
const RTL_LOCALES = new Set([
  "ar", "arc", "dv", "fa", "ha", "he", "khw", "ks",
  "ku", "ps", "ur", "yi",
]);

export function isRTL(locale?: string): boolean {
  const lang = (locale ?? chrome.i18n.getUILanguage()).split("-")[0];
  return RTL_LOCALES.has(lang);
}

export function getDirection(locale?: string): "ltr" | "rtl" {
  return isRTL(locale) ? "rtl" : "ltr";
}

Apply direction in your popup or options page:

// popup.ts
import { isRTL } from "./i18n/rtl";

document.addEventListener("DOMContentLoaded", () => {
  if (isRTL()) {
    document.documentElement.dir = "rtl";
    document.documentElement.lang = chrome.i18n.getUILanguage();
  }
});

CSS patterns for RTL:

/* Use logical properties instead of physical ones */
.sidebar {
  /* Bad: breaks in RTL */
  /* margin-left: 16px; */

  /* Good: works in both directions */
  margin-inline-start: 16px;
}

.icon-label {
  display: flex;
  /* Automatically reverses in RTL */
  gap: 8px;
}

/* For the rare cases where logical properties aren't enough */
[dir="rtl"] .custom-arrow {
  transform: scaleX(-1);
}

Pattern 5: Formatted Dates and Numbers

Use Intl APIs with the extension’s locale for consistent formatting:

// i18n/format.ts
export function formatNumber(
  value: number,
  options?: Intl.NumberFormatOptions
): string {
  const locale = chrome.i18n.getUILanguage();
  return new Intl.NumberFormat(locale, options).format(value);
}

export function formatDate(
  date: Date | number,
  options?: Intl.DateTimeFormatOptions
): string {
  const locale = chrome.i18n.getUILanguage();
  return new Intl.DateTimeFormat(locale, options).format(date);
}

export function formatRelativeTime(date: Date): string {
  const locale = chrome.i18n.getUILanguage();
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
  const diffMs = date.getTime() - Date.now();
  const diffSecs = Math.round(diffMs / 1000);
  const diffMins = Math.round(diffSecs / 60);
  const diffHours = Math.round(diffMins / 60);
  const diffDays = Math.round(diffHours / 24);

  if (Math.abs(diffSecs) < 60) return rtf.format(diffSecs, "second");
  if (Math.abs(diffMins) < 60) return rtf.format(diffMins, "minute");
  if (Math.abs(diffHours) < 24) return rtf.format(diffHours, "hour");
  return rtf.format(diffDays, "day");
}

Usage:

import { formatNumber, formatDate, formatRelativeTime } from "./i18n/format";

// English: "1,234.56"  German: "1.234,56"  Arabic: "١٬٢٣٤٫٥٦"
formatNumber(1234.56);

// English: "3/6/2026"  Japanese: "2026/3/6"
formatDate(new Date());

// English: "2 hours ago"  Spanish: "hace 2 horas"
formatRelativeTime(twoHoursAgo);

Pattern 6: DOM Localization with Data Attributes

Automatically translate static UI elements without manual JavaScript:

<!-- popup.html -->
<h1 data-i18n="popupTitle"></h1>
<p data-i18n="popupDescription"></p>
<button data-i18n="saveButton"></button>
<input data-i18n-placeholder="searchPlaceholder" />
<img data-i18n-alt="logoAlt" src="logo.png" />
// i18n/dom.ts
export function localizeDOM(root: Document | Element = document): void {
  // Text content
  root.querySelectorAll("[data-i18n]").forEach((el) => {
    const key = el.getAttribute("data-i18n")!;
    el.textContent = chrome.i18n.getMessage(key);
  });

  // Attributes
  const attrMap = [
    ["data-i18n-placeholder", "placeholder"],
    ["data-i18n-alt", "alt"],
    ["data-i18n-title", "title"],
    ["data-i18n-aria-label", "aria-label"],
  ] as const;

  for (const [dataAttr, targetAttr] of attrMap) {
    root.querySelectorAll(`[${dataAttr}]`).forEach((el) => {
      const key = el.getAttribute(dataAttr)!;
      el.setAttribute(targetAttr, chrome.i18n.getMessage(key));
    });
  }
}

// Call on DOMContentLoaded
document.addEventListener("DOMContentLoaded", () => localizeDOM());

Pattern 7: Locale-Aware Manifest Fields

Chrome automatically localizes manifest fields prefixed with __MSG_:

{
  "name": "__MSG_extensionName__",
  "description": "__MSG_extensionDescription__",
  "action": {
    "default_title": "__MSG_actionTitle__"
  }
}

This works for:


Pattern 8: Missing Translation Fallback Chain

When a message isn’t available in the user’s locale, implement a fallback chain:

// i18n/fallback.ts
export function getMessageWithFallback(
  key: string,
  substitutions?: string[]
): string {
  // chrome.i18n.getMessage already falls back to default_locale
  const message = chrome.i18n.getMessage(key, substitutions);

  if (message) return message;

  // If even the default locale doesn't have it, return a dev-friendly string
  if (process.env.NODE_ENV === "development") {
    console.warn(`Missing i18n key: "${key}"`);
    return `[${key}]`;
  }

  return key;
}

Validation Script

Catch missing translations before they ship:

// scripts/validate-i18n.ts
import fs from "fs";
import path from "path";

const localesDir = path.resolve("_locales");
const locales = fs.readdirSync(localesDir);

const allKeys = new Map<string, Set<string>>();

for (const locale of locales) {
  const file = path.join(localesDir, locale, "messages.json");
  if (!fs.existsSync(file)) continue;

  const messages = JSON.parse(fs.readFileSync(file, "utf-8"));
  allKeys.set(locale, new Set(Object.keys(messages)));
}

const defaultKeys = allKeys.get("en")!;
let hasErrors = false;

for (const [locale, keys] of allKeys) {
  if (locale === "en") continue;

  const missing = [...defaultKeys].filter((k) => !keys.has(k));
  const extra = [...keys].filter((k) => !defaultKeys.has(k));

  if (missing.length > 0) {
    console.error(`${locale}: missing ${missing.length} keys: ${missing.join(", ")}`);
    hasErrors = true;
  }
  if (extra.length > 0) {
    console.warn(`${locale}: ${extra.length} extra keys: ${extra.join(", ")}`);
  }
}

process.exit(hasErrors ? 1 : 0);

Summary

Pattern Problem It Solves
Type-safe keys Typos in message keys caught at compile time
Pluralization Correct grammar across languages
Dynamic locale User-selectable language preference
RTL support Proper layout for Arabic, Hebrew, etc.
Intl formatting Locale-correct dates, numbers, relative times
DOM localization Declarative UI translation without boilerplate
Validation script Catch missing translations in CI

Combine these patterns with the base i18n guide for complete internationalization coverage in your Chrome extension. -e —

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.