Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I19
30 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I19 · Part 2 · Intermediate

Internationalisation

Translating strings is the easy third. Plurals, formats and layout direction are the rest.

5 min read9 sections2 exercises
30 of 57

In this chapter

  • What the word covers
  • The platform already does formatting
  • react-i18next, for any React app
  • next-intl, for the Next.js App Router
  • Choosing

and 4 more

about 5 min
09On this page
  1. What the word covers
  2. The platform already does formatting
  3. react-i18next, for any React app
  4. next-intl, for the Next.js App Router
  5. Choosing
  6. Gender, choices and fallbacks
  7. Making it a workflow, not a one-off
  8. Layout direction
  9. What goes wrong

What the word covers

Internationalisation (i18n, because of the eighteen letters between the i and the n) is building the app so it can speak more than one language. Localisation (l10n) is supplying one particular language. A team that has done the first can add a locale by adding a file; a team that has not has to rewrite components.

Four things change between locales, and only the first is translation:

  • Text, including plurals, gender and word order.
  • Formats: dates, numbers, currencies, lists, relative times.
  • Direction: Arabic and Hebrew run right to left.
  • Length: German and Finnish routinely run a third longer than English, and a button sized to "Save" breaks.
Never build a sentence out of pieces
"Delete " + count + " " + (count === 1 ? "file" : "files")     // ✗
t("deleteFiles", { count })                                    // ✓ one whole message

Word order differs between languages, so a sentence assembled from fragments cannot be translated: the translator sees "Delete" and "files" as unrelated strings and has no way to move the number. And English has two plural forms, but Russian has three that matter (1, 2–4, 5 and up) and Arabic has six. A ternary on count === 1 is wrong in most of the world.

The platform already does formatting

new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5);
// "1.234,50 €"

new Intl.DateTimeFormat("en-GB", { dateStyle: "long" }).format(new Date());
// "21 September 2026"

new Intl.RelativeTimeFormat("es", { numeric: "auto" }).format(-1, "day");
// "ayer"

new Intl.ListFormat("en", { type: "conjunction" }).format(["a", "b", "c"]);
// "a, b and c"

new Intl.PluralRules("ru").select(3);
// "few"

Before adding a dependency for any of these, use Intl. It is built into every browser and Node, ships the locale data, and is what the libraries below call underneath. Two rules: never format dates or numbers by hand (toFixed and string slicing are not localisation), and pass the locale from your i18n layer instead of letting the browser guess, so the server and client agree.

react-i18next, for any React app

import i18n from "i18next";
import { initReactI18next, useTranslation } from "react-i18next";

i18n.use(initReactI18next).init({
  resources: {
    en: { translation: {
      welcome: "Welcome back, {{name}}",
      file_one: "{{count}} file",
      file_other: "{{count}} files",
    } },
    fr: { translation: {
      welcome: "Bon retour, {{name}}",
      file_one: "{{count}} fichier",
      file_other: "{{count}} fichiers",
    } },
  },
  lng: "en",
  fallbackLng: "en",
  interpolation: { escapeValue: false },     // React already escapes
});

function Files({ n, name }) {
  const { t } = useTranslation();
  return (
    <>
      <h1>{t("welcome", { name })}</h1>
      <p>{t("file", { count: n })}</p>     // picks file_one or file_other
    </>
  );
}

Plural forms are separate keys with the CLDR suffixes _one, _other, and where a language needs them _zero, _two, _few and _many. The variable must be named count, and it must be passed — without it there is no plural lookup at all. Underneath, i18next asks Intl.PluralRules, so the rules are the standard ones.

When a translated sentence contains markup, Trans lets the translator move the tag: "Read our <1>terms</1>" maps index 1 to your <a>. Never split that sentence into three strings around a link.

next-intl, for the Next.js App Router

// i18n/routing.ts
export const routing = defineRouting({ locales: ["en", "de"], defaultLocale: "en" });

// proxy.ts — called middleware.ts before Next.js 16
export default createMiddleware(routing);
export const config = { matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)" };

// app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();
  setRequestLocale(locale);                  // lets the page render statically
  return (
    <html lang={locale}>
      <body><NextIntlClientProvider>{children}</NextIntlClientProvider></body>
    </html>
  );
}

// a Server Component                       // a Client Component
const t = await getTranslations("Inbox");   const t = useTranslations("Inbox");
{ "Inbox": { "unread": "{count, plural, =0 {No messages} one {# message} other {# messages}}" } }

The locale lives in the URL — /de/pricing — because a URL is what search engines index and what a user can share. The proxy negotiates the locale from the Accept-Language header on the first visit and redirects. Messages use the ICU format, so plurals and selects live inside the string, where the translator can see and change them. Server Components read translations on the server, so those strings never reach the browser bundle. What you hand to the client provider is what ships, so pass only the namespaces that Client Components actually use.

Choosing

SituationReach for
Vite, or any client-rendered React appreact-i18next, or FormatJS (react-intl) if you want ICU messages
Next.js App Routernext-intl — it understands Server Components and static rendering
Translators work in a toolWhatever exports to the format that tool accepts; ICU JSON is the common one
One language now, maybe more laterStill extract strings now; retrofitting is the expensive path

Gender, choices and fallbacks

"{gender, select, female {She replied} male {He replied} other {They replied}}"

ICU's select handles the cases a plural cannot: a message that changes with a value that is not a number. Always include other; it is the branch used when the value is anything unexpected, and it is what keeps a new enum value from producing an empty string. Locales also fall back in a chain — a French-Canadian visitor (fr-CA) should see fr before English — and the last stop must be a complete catalogue, so a missing key shows real text rather than the key itself.

Making it a workflow, not a one-off

  • Extract, do not hand-maintain. A tool that scans the code for message calls and writes the catalogue means the source and the file cannot drift apart.
  • Fail CI on missing keys. A locale with a key absent in one file is a bug you can find in seconds, or one a customer in Munich finds in production.
  • Give translators context. "Post" is a noun on a blog and a verb on a button. A short description alongside the key is worth more than any tool.
  • Type the keys. With TypeScript, both major libraries can check t("settings.svae") at compile time against the English catalogue.
  • Keep the default language honest. If English is only a fallback and nobody reads it, its typos ship.

Layout direction

<html lang="ar" dir="rtl">

.card { margin-inline-start: 1rem; padding-inline: 1rem; }   /* not margin-left */

Set lang and dir on the root element, and write CSS with logical properties — margin-inline-start, padding-inline, inset-inline-end, text-align: start — so the layout mirrors without a second stylesheet. Icons that imply direction, like a back arrow, need to flip; icons that do not, like a clock, must not.

What goes wrong

  • Keys named after the English text. "Save" as a key breaks the day marketing changes it to "Save changes". Name by meaning: settings.save.
  • Translated strings in state or the database. Store the key or the raw value and translate at render, or the text stays in the old language when the user switches.
  • Hydration mismatch. A date formatted on the server in one timezone and in the browser in another renders two different strings. Format with an explicit locale and timeZone.
  • Bundle weight. Loading every language for every user is the default failure. Load the active locale, and split large catalogues by page.
  • Untested layouts. Pseudo-localisation — automatically stretching every string with accented characters — finds truncation and hard-coded text before a translator does.
  • SEO. One URL per locale, lang on the page, and hreflang alternates linking the versions.
Interview answer, one sentence

"Internationalisation is text, formats, direction and length: whole messages with ICU or CLDR plural rules instead of concatenation, the built-in Intl APIs for formatting, the locale in the URL, logical CSS properties for right-to-left, and translation resolved at render time, never stored."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsChoose the right plural formintermediate
  • 5 testsA translate function that follows the active localeintermediate
All 66 for this level →
I19

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I18Previous · I18End-to-end testing
  2. I19you are here
  3. Next · I20File uploadsA file input works without any of your code. Progress, drag and drop and large files are what you add.5 min read

01On this page

9 sections · 0%

  1. 01What the word covers
  2. 02The platform already does formatting
  3. 03react-i18next, for any React app
  4. 04next-intl, for the Next.js App Router
  5. 05Choosing
  6. 06Gender, choices and fallbacks
  7. 07Making it a workflow, not a one-off
  8. 08Layout direction
  9. 09What goes wrong

[] chapters · t top