i18nIntermediatean afternoon for the setup; string migration is ongoing

Add internationalization (i18n)

Your app only speaks one language, and every user-facing string is hardcoded in JSX. i18n is the discipline of pulling those strings out into per-locale message catalogs and formatting numbers, dates, and plurals correctly for each locale — so you can serve users in their own language without forking your UI.

the approach

Think of i18n as separating "what the app says" from "how the app works." Every visible string becomes a key that points into a per-locale catalog (usually JSON), and your components render `t('key')` instead of literal text. The two things beginners underestimate: (1) grammar is not string concatenation — plurals, gender, and word order differ per language, which is why you use ICU MessageFormat instead of gluing sentence fragments together; and (2) numbers, currency, and dates must go through the native `Intl` API, never manual formatting. Pick the library that matches your framework (next-intl for Next.js App Router, Paraglide JS or react-i18next elsewhere), get locale routing working first, then migrate strings incrementally feature by feature.

Step by step

  1. 1

    Pick a library and set up locale routing

    For Next.js App Router use next-intl; for other React/Vite apps use Paraglide JS (compiler-based, tree-shakeable, type-safe) or react-i18next. Get the middleware and `/[locale]/` URL segments working with a default locale before touching any strings — a locale prefix in the URL (`/nl/pricing`) is what makes SEO and locale detection sane.

  2. 2

    Externalize strings into message catalogs

    Create one JSON file per locale (`messages/en.json`, `messages/nl.json`) and namespace keys by component or feature, not by page. Move existing hardcoded text in, one feature at a time. Keep the source language (usually English) as the single source of truth; never hand-edit translated catalogs.

  3. 3

    Use ICU MessageFormat for plurals and variables

    Any string with a count, a name, or a date needs ICU syntax: `{count, plural, one {# item} other {# items}}`. This is the whole point — it lets translators express grammar rules your code can't hardcode. Never build sentences by concatenating translated pieces; word order changes per language.

  4. 4

    Format numbers, dates, and currency with Intl

    Route every number, price, and date through the native `Intl.NumberFormat` / `Intl.DateTimeFormat` (exposed as `useFormatter()` in next-intl). This gives you `1.234,56 €` in Dutch and `$1,234.56` in US English for free. Manual `.toFixed(2)` or `MM/DD/YYYY` formatting is a bug in disguise.

  5. 5

    Handle locale detection, switching, and persistence

    Detect the initial locale from the URL, then fall back to the `Accept-Language` header. Add a language switcher that navigates to the same route under a new locale prefix, and persist the choice in a cookie. Emit `hreflang` alternate links so search engines index each language.

  6. 6

    Support RTL and locale-aware layout

    Set `<html lang={locale} dir={dir}>` and use CSS logical properties (`margin-inline-start`, not `margin-left`) so Arabic and Hebrew mirror correctly. Even if you only ship LTR languages today, using logical properties now costs nothing and saves a painful refactor later.

  7. 7

    Wire up a translation workflow and test

    Push source strings to a TMS like Tolgee (great in-context editing) or Crowdin so non-engineers can translate. Turn on pseudolocalization (accented, elongated fake text) in dev to instantly reveal any string you forgot to externalize and any layout that breaks on longer German words.

Starter snippet

tsx
// messages/en.json
// { "Cart": { "summary": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" } }

// app/[locale]/cart.tsx  (Next.js App Router + next-intl)
import { useTranslations, useFormatter } from 'next-intl';

export default function CartSummary({ count, total }: { count: number; total: number }) {
  const t = useTranslations('Cart');
  const format = useFormatter();

  return (
    <aside>
      {/* ICU plural picks the right grammar per locale */}
      <p>{t('summary', { count })}</p>
      {/* Intl handles locale-correct currency: $1,234.56 vs 1.234,56 € */}
      <p>{format.number(total, { style: 'currency', currency: 'EUR' })}</p>
    </aside>
  );
}

✕ Watch out for

  • Concatenating translated fragments (`t('you have') + count + t('items')`). Word order differs per language — this produces broken grammar everywhere. Use one ICU string with variables instead.
  • Skipping ICU plurals, so you ship '1 items' and '0 item'. Many languages have more than two plural forms (Polish, Russian, Arabic); only ICU handles them.
  • Formatting numbers and dates by hand, hardcoding US conventions. `1,234.56` and `MM/DD/YYYY` are wrong for most of the world — always go through Intl.
  • Shipping every locale's full catalog to the browser, bloating the client bundle. Load only the active locale, and prefer server components / compiler-based tools like Paraglide that tree-shake unused messages.
  • Hardcoded `left`/`right` in CSS and a missing `dir` attribute, so any future RTL language is a rewrite. Use logical properties and set `dir` from the locale from day one.

✓ Pro tips

  • When you hand this to an AI coding agent, be explicit: 'Extract every user-facing string into next-intl message catalogs, replace them with `t()` calls namespaced by component, use ICU plural syntax for anything with a count, and route all numbers/dates through the formatter — do NOT concatenate translated fragments.' Agents love string-gluing, so this constraint matters.
  • After the agent runs, verify its work mechanically: grep for literal text still living in JSX (e.g. search for `>[A-Z][a-z]` between tags) and run a production build to surface missing keys. Then spot-check that it actually used `{count, plural, ...}` and `Intl`/`useFormatter`, not template strings.
  • Turn on pseudolocalization during migration. Any string that stays in plain English on screen is one you (or the agent) missed, and it catches layout breakage before real translators are involved.
  • Keep the source-language catalog as the only file humans edit by hand; generate and sync all other locales through your TMS so translations never drift out of key-sync with the code.