Frontend Internationalization at Scale

How I wire i18n into a React/Next.js app that ships in a dozen locales: i18next, ICU plurals, RTL with logical properties, lazy bundles, Intl APIs, and CI checks for missing keys.

It started with a German creator tweeting a screenshot of someone else’s profile photo on his own profile preview. This was at a live-video creator platform I led engineering at. Our edge caching layer had shipped a small refactor the day before. The new cache key included the path. It dropped the locale segment. The cache happily served whatever response it had seen first, in whatever language, to whoever showed up next.

The bug was about four lines of code. The lesson was bigger. Locale is not a string you sprinkle through your components. It’s a tenant. It belongs in routing, cache keys, CSS, and CI, and if you treat it like decoration, you’ll find out the hard way.

I’ve shipped i18n in React apps for creator products, sports federation platforms, and a couple of side projects. The pattern that survives is boring and consistent. The one that breaks is the same one every junior team reaches for: one giant JSON, sprinkle t() calls, ship it. Works for 3 locales. Dies at 12.

Why naive i18n breaks at scale

The first version always looks the same. One locales/en.json, one locales/tr.json, loaded eagerly at app boot, a t('cta.subscribe') here, a t('checkout.title') there. Things go sideways when the bundle is ~600KB of strings no user will read this session, when SSR renders English while the client boots German and React screams hydration mismatch on every label, when someone writes ${count === 1 ? 'item' : 'items'} because nobody told them Polish has three plural forms, and when a creator shares their profile and gets the wrong locale’s preview card because the edge cache didn’t know about locale.

That last one is the one I’ve actually paged on.

i18next setup I actually trust

I default to i18next + react-i18next. Not because it’s perfect, but because it has the plugins I need (ICU, lazy loading, a Next.js companion), the API is small, and I’ve shipped it in enough places to know its sharp edges.

The two changes I always make on day one: namespaces per route, and lazy loading.

// src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";

export const SUPPORTED_LOCALES = ["en", "tr", "de", "fr", "es", "pt-BR", "ja", "ar"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];

const RTL_LOCALES = new Set<Locale>(["ar"]);

export function isRtl(locale: Locale) {
  return RTL_LOCALES.has(locale);
}

export async function initI18n(locale: Locale, namespaces: string[] = ["common"]) {
  if (!i18n.isInitialized) {
    await i18n
      .use(ICU)
      .use(initReactI18next)
      .use(
        resourcesToBackend(
          (lng: string, ns: string) =>
            import(`../../locales/${lng}/${ns}.json`).then((m) => m.default)
        )
      )
      .init({
        lng: locale,
        fallbackLng: "en",
        ns: namespaces,
        defaultNS: "common",
        interpolation: { escapeValue: false },
        react: { useSuspense: true },
        // throw in dev so missing keys can't slip into a PR
        saveMissing: false,
        missingKeyHandler: (_lng, _ns, key) => {
          if (process.env.NODE_ENV !== "production") {
            throw new Error(`Missing i18n key: ${key}`);
          }
        },
      });
    return i18n;
  }

  if (i18n.language !== locale) await i18n.changeLanguage(locale);
  await Promise.all(namespaces.map((ns) => i18n.loadNamespaces(ns)));
  return i18n;
}

Two things to notice. The backend is a dynamic import() keyed by locale and namespace, so the bundler splits each (locale, namespace) pair into its own chunk. And the missing-key handler throws in dev. That last one will annoy your team for about a week. Then it pays for itself for years.

ICU plurals and select for real copy

The day someone files a bug that says “the Polish plurals look broken”, you’ll learn that English’s one/many split is a lucky accident, not a universal rule. Polish has three forms. Arabic has six. Russian, Czech, Lithuanian all have their own logic.

Don’t roll this yourself. Use ICU MessageFormat through i18next-icu.

// locales/en/cart.json
{
  "summary": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}",
  "shipping": "{kind, select, free {Free shipping} flat {Flat rate: {amount}} other {Calculated at checkout}}"
}
// locales/pl/cart.json
{
  "summary": "{count, plural, =0 {Twój koszyk jest pusty} one {# produkt} few {# produkty} many {# produktów} other {# produktu}}"
}

The React side stays clean:

import { useTranslation } from "react-i18next";

export function CartSummary({ count, shipping }: { count: number; shipping: { kind: string; amount?: string } }) {
  const { t } = useTranslation("cart");
  return (
    <div className="ps-4 pe-4">
      <p>{t("summary", { count })}</p>
      <p>{t("shipping", { kind: shipping.kind, amount: shipping.amount })}</p>
    </div>
  );
}

Notice ps-4 pe-4 instead of pl-4 pr-4. That’s the next thing.

RTL with logical properties

I used to ship a separate rtl.css for Arabic with hand-flipped paddings and margins. It was always two weeks behind the LTR styles. Then logical properties got browser support and the whole problem went away.

Use margin-inline-start instead of margin-left. Use padding-inline-end instead of padding-right. The “start” and “end” sides flip automatically when dir="rtl". Tailwind exposes this as ms-*, me-*, ps-*, pe-*, start-*, end-*.

// app/layout.tsx (Next.js)
import { isRtl, type Locale } from "@/i18n";

export default function RootLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: Locale };
}) {
  const dir = isRtl(locale) ? "rtl" : "ltr";
  return (
    <html lang={locale} dir={dir}>
      <body className="ps-6 pe-6">{children}</body>
    </html>
  );
}

A few things still need manual care. Chevron icons should flip (rtl:scale-x-[-1] in Tailwind, or a <ChevronEnd /> component that picks the right glyph). Tabular numbers stay LTR even inside RTL text. Fonts often need a locale-specific override because the default Latin font doesn’t ship the Arabic glyphs you want.

Lazy loading translation bundles

The eager-load pattern means every locale ships every key for every page. A few hundred kilobytes per locale, multiplied by however many you support, downloaded by people who only ever read in one of them. It adds up.

With the i18next setup above, each (locale, namespace) pair becomes a chunk. On the server side, a small middleware decides which locale to use and warms the right chunks before render. On the edge, locale becomes part of the cache key. Always.

// middleware.ts (Next.js)
import { NextResponse, type NextRequest } from "next/server";
import { SUPPORTED_LOCALES } from "@/i18n";

function pickLocale(req: NextRequest): string {
  const cookie = req.cookies.get("NEXT_LOCALE")?.value;
  if (cookie && SUPPORTED_LOCALES.includes(cookie as never)) return cookie;

  const header = req.headers.get("accept-language") ?? "";
  const top = header.split(",")[0]?.split(";")[0]?.trim().toLowerCase() ?? "en";
  const base = top.split("-")[0];

  const match =
    SUPPORTED_LOCALES.find((l) => l.toLowerCase() === top) ??
    SUPPORTED_LOCALES.find((l) => l.toLowerCase().startsWith(base)) ??
    "en";
  return match;
}

export function middleware(req: NextRequest) {
  const locale = pickLocale(req);
  const res = NextResponse.next();
  res.headers.set("x-locale", locale);
  // CDN: vary on locale, never share cache across languages
  res.headers.set("Vary", "Accept-Language, Cookie");
  res.headers.set("Cache-Tag", `locale:${locale}`);
  return res;
}

This is the incident from the opening, unpacked. New worker version, new cache key, the locale segment was just… missing. The key was ${path}|${og_version}. Cache stored the first response it saw per path and served it to everyone. EU users got US users’ Open Graph cards. The German creator’s tweet had a couple hundred retweets in an hour.

First reaction was the wrong one: hit the global Cloudflare purge button. The cache repopulated within minutes, with the same bad key, producing the same wrong results. The code was still broken.

Real fix was two steps. Rolled the worker back to the previous version (Workers rollback is instant, which saved us), then redeployed with locale in the key: ${path}|${locale}|${og_version}. The longer-term fix was a deploy-time check that diffs cache-key composition between the previous and next worker version, refusing to deploy a key change without an explicit --migrate-cache-key flag that requires PR review. About 40 minutes of mis-shared previews. No data leak, but the appearance of one is sometimes worse than the real thing.

Cache keys are part of your public API. Treat any change to key composition like a schema migration. Locale is always part of the key.

Intl APIs that beat library helpers

I used to import moment-locale or dayjs/locale to format dates and numbers. I don’t anymore. The Intl.* family does the job natively, with the right CLDR data, and zero bundle cost.

import { useMemo } from "react";

export function useFormatters(locale: string) {
  return useMemo(
    () => ({
      number: new Intl.NumberFormat(locale),
      currency: (code: string) =>
        new Intl.NumberFormat(locale, { style: "currency", currency: code }),
      date: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }),
      dateTime: new Intl.DateTimeFormat(locale, {
        dateStyle: "medium",
        timeStyle: "short",
      }),
      relativeTime: new Intl.RelativeTimeFormat(locale, { numeric: "auto" }),
      list: new Intl.ListFormat(locale, { style: "long", type: "conjunction" }),
      plural: new Intl.PluralRules(locale),
    }),
    [locale]
  );
}

Intl.RelativeTimeFormat handles “yesterday” / “in 3 hours” without a library. Intl.ListFormat does “Akin, Maria, and Jonas” vs the Spanish “Akin, Maria y Jonas” vs the Arabic version. Intl.PluralRules gives you the CLDR plural category for a number in any locale, which is useful when you want to pick an icon or a different visual treatment, not a string.

The one thing I still reach for date-fns or Luxon on is parsing and arithmetic. Formatting belongs in Intl.

CI checks for missing keys

This is the part most teams skip and then regret. Translators are not infinite. PRs ship English copy. Locales drift. A label in Turkish shows up as the literal key checkout.summary.tax_estimate to a real user because someone forgot to ping the translation team.

I run a small Node script in CI that diffs key sets across locales. It also flags keys present in code but missing from English (typo catch), and keys present in JSON but never referenced in code (cleanup).

// scripts/i18n-check.ts
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { globSync } from "glob";

const LOCALES_DIR = "locales";
const SOURCE_LOCALE = "en";

function flatKeys(obj: unknown, prefix = ""): string[] {
  if (typeof obj !== "object" || obj === null) return [];
  return Object.entries(obj).flatMap(([k, v]) => {
    const next = prefix ? `${prefix}.${k}` : k;
    return typeof v === "object" && v !== null ? flatKeys(v, next) : [next];
  });
}

function loadNamespace(locale: string, ns: string) {
  return JSON.parse(readFileSync(join(LOCALES_DIR, locale, ns), "utf8"));
}

const locales = readdirSync(LOCALES_DIR);
const namespaces = readdirSync(join(LOCALES_DIR, SOURCE_LOCALE));

const sourceKeys = new Map<string, Set<string>>();
for (const ns of namespaces) {
  sourceKeys.set(ns, new Set(flatKeys(loadNamespace(SOURCE_LOCALE, ns))));
}

const missing: string[] = [];
for (const locale of locales) {
  if (locale === SOURCE_LOCALE) continue;
  for (const ns of namespaces) {
    const target = new Set(flatKeys(loadNamespace(locale, ns)));
    for (const key of sourceKeys.get(ns) ?? []) {
      if (!target.has(key)) missing.push(`${locale}/${ns}: ${key}`);
    }
  }
}

// usage scan: any t('foo.bar') in code that doesn't exist in en
const sourceFiles = globSync("src/**/*.{ts,tsx}");
const codeKeys = new Set<string>();
const KEY_RE = /\bt\(\s*['"]([\w.\-]+)['"]/g;
for (const file of sourceFiles) {
  const text = readFileSync(file, "utf8");
  for (const m of text.matchAll(KEY_RE)) codeKeys.add(m[1]);
}

const allSourceKeys = new Set<string>();
for (const set of sourceKeys.values()) for (const k of set) allSourceKeys.add(k);

const unused: string[] = [];
for (const k of allSourceKeys) if (!codeKeys.has(k)) unused.push(k);

const orphans: string[] = [];
for (const k of codeKeys) if (!allSourceKeys.has(k)) orphans.push(k);

if (missing.length || orphans.length) {
  console.error("i18n check failed");
  for (const m of missing) console.error("  missing:", m);
  for (const o of orphans) console.error("  orphan (in code, not in en):", o);
  process.exit(1);
}
if (unused.length) console.warn("unused keys:", unused.length);

Wired into a workflow:

# .github/workflows/i18n-check.yml
name: i18n
on:
  pull_request:
    paths:
      - "locales/**"
      - "src/**/*.ts"
      - "src/**/*.tsx"
      - "scripts/i18n-check.ts"
jobs:
  i18n:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsx scripts/i18n-check.ts

Same platform, different lesson, same shape. We were mid-migration on a shared design system, about 70% on the new utility-class bundle, 30% legacy. Five PRs went out one Friday afternoon. CI passed, deploy ran, and 30 minutes later support pinged: creator bios were missing. Not broken, missing. A global CSS reset in the new bundle was nuking margin on <section> elements and the container collapsed to zero height. The first hotfix patched the bio specifically. Within an hour three more sections were reported broken elsewhere. Real fix was rolling the bundle back and re-shipping with the reset scoped to a data-attribute boundary, plus Chromatic snapshots on the top routes with tight diff thresholds.

The parallel for i18n is the same shape. In a partially-migrated codebase, “global” anything is a footgun. Global key sets, global resets, global cache keys. CI is where you draw the boundary, because reviewers will miss it.

Takeaways

  • Lazy-load namespaces per route. Don’t ship every key to every page.
  • ICU plurals and select. Skip them and your Polish and Arabic copy will be wrong.
  • Logical CSS properties (margin-inline-start, Tailwind ms-*/me-*) make RTL almost free if you start early.
  • Use Intl.* natively. Skip the date library for formatting.
  • Locale belongs in every cache key. Treat it like a tenant id.
  • Fail CI on missing keys. Translators are not infinite, and a literal key shipping to a user is worse than a placeholder.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.