| |

React 115 ⚛️ Internationalization (i18n) and Localization in React

⏱️ Estimated read: 15 min | |

Internationalization in React is not a single problem wearing three masks. It is three problems that share a name: translating text, formatting locale-sensitive data like dates and currencies, and structuring the application so the right language reaches the right user. The first two are largely solved by the platform—the browser’s Intl object handles formatting natively—while the third depends on the framework and the rendering model. A client-rendered React application has different i18n constraints than a Next.js App Router application where Server Components and Client Components share a single page.

This chapter covers the concepts and tooling that make a React application multilingual. It begins with the browser-native Intl API, which requires no library and should be the foundation of any formatting strategy. It then examines the two dominant library approaches: react-i18next for client-rendered React applications, and next-intl for Next.js App Router applications with Server Components. The chapter addresses the architectural decision that precedes the library choice—whether the locale lives in the URL, in a cookie, or in user settings—and the performance implications of passing translation data across the server-client boundary. It closes with the testing practice that catches untranslated strings before they reach production.


Key point: Intl is the only formatting API you should ever need. Libraries like react-i18next and next-intl handle translation lookup and locale management, but they delegate date, number, and currency formatting to Intl under the hood. Learn Intl first; the library is a convenience, not a replacement.


Why i18n is not just translation

The formatting problem. A date like 10/12/2025 means October 12 in the United States and December 10 in much of Europe. A number like 1,234.56 uses a comma as a thousands separator in English and a period in German, with the decimal separator reversed. Currency symbols move depending on the locale: $100 in English, 100 $ in French. These are not translation problems. They are formatting problems, and they are solved by the Intl API, not by a translation library .

The routing problem. A multilingual application must decide how the locale appears in the URL. The common options are a path prefix (/en/about, /de/about), a subdomain (en.example.com), or a query parameter. Each choice affects SEO, caching, and the routing configuration. In Next.js, the [locale] dynamic segment is the conventional mechanism for prefix-based routing, and next-intl integrates with the App Router through this segment . For client-rendered React applications, the router must be configured to handle the prefix or the locale must be stored outside the URL.

The detection problem. The user’s preferred language can come from several sources: the Accept-Language header, a cookie set by a previous visit, a user profile setting, or the geographic location of the request. The priority order matters. A user who explicitly selected French should not be redirected to English because their browser header says English. i18next-browser-languagedetector handles the browser sources for client-rendered apps ; Next.js middleware handles the server-side detection and redirect for App Router applications .

The translation management problem. Translation files grow. Keys get duplicated, renamed, or orphaned. Translators need a workflow that does not involve editing JSON files by hand. The tooling around react-i18next includes CLI tools for key extraction and type generation , and commercial services like Locize for translation management. The choice of library is partly a choice of the surrounding ecosystem.


a. The Intl API: formatting without a library

The ECMAScript Internationalization API is available globally as Intl. It requires no installation, no configuration, and no bundle size beyond what the browser already ships. The two primary constructors for React applications are Intl.DateTimeFormat and Intl.NumberFormat, with Intl.RelativeTimeFormat and Intl.PluralRules covering the remaining common cases.

Intl.DateTimeFormat takes a locale and an options object and returns a formatter. The modern approach uses dateStyle and timeStyle rather than individual field options, which produces a locale-appropriate format without the developer having to know the conventions .

// A date formatted for US English.
const usDate = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'long',
  timeZone: 'America/New_York',
}).format(new Date('2025-10-12'));
// "October 12, 2025"

// The same date formatted for German.
const deDate = new Intl.DateTimeFormat('de-DE', {
  dateStyle: 'long',
  timeZone: 'Europe/Berlin',
}).format(new Date('2025-10-12'));
// "12. Oktober 2025"

Intl.NumberFormat handles currency and unit formatting. The style option selects the format, and the currency option takes an ISO 4217 code. The symbol position, spacing, and decimal separator are all determined by the locale .

// Currency in US English: symbol before the number.
new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
}).format(1234.56);
// "$1,234.56"

// Currency in German: symbol after the number, comma decimal.
new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: 'EUR',
}).format(1234.56);
// "1.234,56 €"

Intl.RelativeTimeFormat produces strings like “2 days ago” or “in 3 months.” The numeric: 'auto' option uses special words like “yesterday” and “tomorrow” when the locale has them, falling back to numeric expressions otherwise.

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day');  // "yesterday"
rtf.format(-3, 'day');  // "3 days ago"

const rtfDe = new Intl.RelativeTimeFormat('de', { numeric: 'auto' });
rtfDe.format(-1, 'day'); // "gestern"

The Intl API does not translate text. It formats data. A React application that uses Intl for dates and numbers and a translation library for strings has a complete i18n stack with minimal redundancy.


b. react-i18next for client-rendered React

react-i18next is the React binding for i18next, the core translation engine. The library provides a useTranslation hook that returns a t function and the i18n instance. The t function looks up a key in the active language’s translation resources and returns the translated string .

The setup requires two packages: i18next for the core and react-i18next for the React integration. The initialization creates an i18n instance, attaches the React plugin with initReactI18next, and configures the resources and fallback language .

// i18n.ts — initialization
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: { welcome: 'Welcome to React' } },
      de: { translation: { welcome: 'Willkommen bei React' } },
    },
    lng: 'en',
    fallbackLng: 'en',
    interpolation: {
      // React already escapes values, so i18next should not.
      escapeValue: false,
    },
  });

export default i18n;

The useTranslation hook is the primary interface in functional components. It returns t for translation and i18n for language switching and metadata .

import { useTranslation } from 'react-i18next';

function Welcome() {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <h1>{t('welcome')}</h1>
      <button onClick={() => i18n.changeLanguage('de')}>
        Deutsch
      </button>
    </div>
  );
}

The Trans component handles translations that contain JSX elements. A sentence like “Hello {name}, you have {count} messages” cannot be expressed as a single string without breaking the markup. Trans accepts the JSX tree and extracts the translatable parts .

import { Trans } from 'react-i18next';

<Trans i18nKey="userMessages" count={count}>
  Hello <strong>{{name}}</strong>, you have {{count}} messages.
</Trans>

For client-rendered applications, the translation files are typically loaded over HTTP. The i18next-http-backend plugin fetches JSON files from /public/locales/{lng}/{ns}.json, and i18next-browser-languagedetector selects the initial language from the browser . The Suspense component handles the loading state while the translations are fetched.


c. next-intl for the Next.js App Router

The App Router changes the i18n problem because Server Components cannot use hooks. A hook like useTranslation requires a client boundary and a React context. next-intl solves this by providing two APIs: a hook-based API for Client Components and an awaitable API for async Server Components .

The setup begins with the next-intl plugin in next.config.ts, which creates an alias for the request-scoped configuration .

// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
export default withNextIntl({});

The request configuration is defined in i18n/request.ts. It returns the locale and the messages for the current request. The locale can come from a cookie, a header, or a route parameter .

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async () => {
  const locale = 'en'; // or read from cookies/headers
  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});

In Server Components, the translation function comes from useTranslations for synchronous components or getTranslations for async components. The API is the same shape: a namespace argument, then a key lookup .

// app/page.tsx — Server Component
import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations('HomePage');
  return <h1>{t('title')}</h1>;
}

For Client Components, the NextIntlClientProvider must wrap the tree in the root layout. The provider inherits the locale from the server configuration but requires the messages to be passed explicitly .

// app/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';

export default async function RootLayout({ children }) {
  const locale = await getLocale();
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <NextIntlClientProvider messages={messages}>
        {children}
      </NextIntlClientProvider>
    </html>
  );
}

The performance consideration is the size of the messages passed to the client. Passing all messages to every Client Component increases the JavaScript bundle. The recommended approach is to pass translated labels as props from Server Components where possible, and to use NextIntlClientProvider with messages={null} for subtrees that do not need client-side translation.


Complete Example Session

// ============================================
// PART 1: THE INTL FORMATTERS
// ============================================
// Native formatting, no library required.

const formatDate = (date: Date, locale: string) =>
  new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date);

const formatPrice = (amount: number, locale: string, currency: string) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);

// ============================================
// PART 2: THE REACT-I18NEXT SETUP
// ============================================
// Client-rendered React application.

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init({
  resources: {
    en: { translation: { cart: 'Your cart', total: 'Total' } },
    de: { translation: { cart: 'Ihr Warenkorb', total: 'Gesamt' } },
  },
  lng: 'en',
  fallbackLng: 'en',
  interpolation: { escapeValue: false },
});

// ============================================
// PART 3: THE TRANSLATED COMPONENT
// ============================================
// useTranslation provides t and i18n.

import { useTranslation } from 'react-i18next';

function Cart({ total, locale }: { total: number; locale: string }) {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <h2>{t('cart')}</h2>
      <p>{t('total')}: {formatPrice(total, i18n.language, 'USD')}</p>
    </div>
  );
}

// ============================================
// PART 4: THE LANGUAGE SWITCHER
// ============================================
// changeLanguage updates the active locale.

function LocaleSwitcher() {
  const { i18n } = useTranslation();
  return (
    <select
      value={i18n.language}
      onChange={(e) => i18n.changeLanguage(e.target.value)}
    >
      <option value="en">English</option>
      <option value="de">Deutsch</option>
    </select>
  );
}

// ============================================
// PART 5: THE NEXT-INTL SETUP
// ============================================
// Server-side configuration for App Router.

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async () => {
  const locale = 'en';
  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});

// ============================================
// PART 6: THE SERVER COMPONENT
// ============================================
// useTranslations works in synchronous components.

import { useTranslations } from 'next-intl';

function ServerCart({ total }: { total: number }) {
  const t = useTranslations('Cart');
  return <p>{t('total')}</p>;
}

// ============================================
// PART 7: THE ASYNC SERVER COMPONENT
// ============================================
// getTranslations for async components.

import { getTranslations } from 'next-intl/server';

async function AsyncCart({ total }: { total: number }) {
  const t = await getTranslations('Cart');
  return <p>{t('total')}: {formatPrice(total, 'en', 'USD')}</p>;
}

// ============================================
// PART 8: THE CLIENT PROVIDER
// ============================================
// Client Components need the provider.

import { NextIntlClientProvider } from 'next-intl';

function ClientWrapper({ children, messages }: any) {
  return (
    <NextIntlClientProvider messages={messages}>
      {children}
    </NextIntlClientProvider>
  );
}

// ============================================
// PART 9: THE PROPS STRATEGY
// ============================================
// Prefer passing translated labels to Client Components.

function Expandable({ title, children }: { title: string; children: React.ReactNode }) {
  'use client';
  return <details><summary>{title}</summary>{children}</details>;
}

function FAQEntry() {
  const t = useTranslations('FAQ');
  return <Expandable title={t('title')}>{t('content')}</Expandable>;
}

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// Intl for formatting. Library for translation.
// Architecture for routing and detection.

export default function App() {
  return (
    <div>
      <Cart total={99.99} locale="en" />
      <LocaleSwitcher />
    </div>
  );
}

The ten parts build both the client-rendered and server-rendered patterns, with Intl as the formatting foundation. The key observation is that the translation library handles string lookup and locale state, while Intl handles the actual formatting. Neither replaces the other, and a complete application uses both.


Quick Reference

Intl Constructors

ConstructorPurposeExample
Intl.DateTimeFormatDates and timesnew Intl.DateTimeFormat('de', { dateStyle: 'long' })
Intl.NumberFormatNumbers, currency, unitsnew Intl.NumberFormat('en', { style: 'currency', currency: 'USD' })
Intl.RelativeTimeFormat“2 days ago”new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
Intl.PluralRulesPlural category selectionnew Intl.PluralRules('en').select(1)

Library Comparison

Featurereact-i18nextnext-intl
Rendering modelClientServer + Client
Hook APIuseTranslationuseTranslations
Async APIN/AgetTranslations
ProviderNone neededNextIntlClientProvider
Routing integrationManualBuilt-in [locale] support

Locale Routing Options

OptionExampleSEO Impact
Path prefix/en/aboutStrong
Subdomainen.example.comStrong
Query parameter/about?lang=enWeak
CookieNo URL changeNone

Formatting Options

OptionEffectApplies To
dateStyle: 'long'“October 12, 2025”DateTimeFormat
style: 'currency'“$1,234.56”NumberFormat
numeric: 'auto'“yesterday”RelativeTimeFormat
timeZone: 'UTC'Fix timezoneDateTimeFormat

Best Practices

✅ Do This:

// Use Intl for all date and number formatting.
new Intl.DateTimeFormat(locale, { dateStyle: 'long' })                // ✅

// Pass translated labels as props to Client Components.
<Expandable title={t('title')}>...</Expandable>                       // ✅

// Use numeric: 'auto' for relative times.
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })              // ✅

// Keep translation JSON files colocated with the code.
messages/en.json                                                      // ✅

// Use the awaitable API in async Server Components.
const t = await getTranslations('Namespace')                          // ✅

❌ Don’t Do This:

// Don't format dates with toLocaleString without options.
date.toLocaleString()  // locale-dependent but uncontrolled           // ❌

// Don't pass all messages to every Client Component.
<NextIntlClientProvider messages={allMessages}>                       // ❌

// Don't hardcode currency symbols.
`$${price}`                                                           // ❌

// Don't use a translation library for number formatting.
t('price', { value: 1234.56 })  // Intl is better                     // ❌

// Don't forget the timeZone option for server-rendered dates.
// The server's timezone may differ from the user's.                  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Wrong date orderAmbiguous numeric formatUse dateStyle: 'long'
Currency symbol misplacedHardcoded formatUse Intl.NumberFormat
Hook error in Server ComponentuseTranslations needs syncUse getTranslations for async
Messages missing on clientProvider not configuredPass messages to NextIntlClientProvider
Locale not changingRouting not configuredUse [locale] segment or changeLanguage
Bundle size from messagesAll messages sent to clientPass props instead
Hydration mismatchServer/client locale differAlign locale source

Real-World Examples

1. Format Date

new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date)

2. Format Currency

new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(99.99)

3. Relative Time

new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-1, 'day')

4. react-i18next Hook

const { t } = useTranslation();

5. Trans Component

<Trans i18nKey="msg" count={count}>Hello <strong>{{name}}</strong></Trans>

6. Change Language

i18n.changeLanguage('de')

7. next-intl Server Hook

const t = useTranslations('HomePage');

8. next-intl Async

const t = await getTranslations('HomePage');

9. Client Provider

<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>

10. Locale Segment

app/[locale]/page.tsx

Visual

The i18n Stack

┌─────────────────────────────────────────────────────────┐
│  APPLICATION LAYER                                      │
│                                                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │  TRANSLATION LIBRARY                              │  │
│  │  react-i18next / next-intl                        │  │
│  │  String lookup, locale state, plurals             │  │
│  └───────────────────────────────────────────────────┘  │
│                         │                               │
│                         ▼                               │
│  ┌───────────────────────────────────────────────────┐  │
│  │  INTL API                                         │  │
│  │  Intl.DateTimeFormat, Intl.NumberFormat           │  │
│  │  Dates, numbers, currency, relative time          │  │
│  └───────────────────────────────────────────────────┘  │
│                         │                               │
│                         ▼                               │
│  ┌───────────────────────────────────────────────────┐  │
│  │  BROWSER / NODE RUNTIME                           │  │
│  │  ICU data, locale data                            │  │
│  └───────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

Locale Routing in Next.js

┌─────────────────────────────────────────────────────────┐
│  /en/about          → English                           │
│  /de/about          → German                            │
│                                                         │
│  app/                                                   │
│  └── [locale]/                                          │
│      ├── layout.tsx   ← NextIntlClientProvider          │
│      └── page.tsx     ← useTranslations                 │
│                                                         │
│  middleware.ts redirects / → /en (default)              │
│  next-intl plugin reads i18n/request.ts                 │
└─────────────────────────────────────────────────────────┘

Server vs Client Translation

┌─────────────────────────────────────────────────────────┐
│  SERVER COMPONENT                                       │
│                                                         │
│  useTranslations()  → sync, returns t                   │
│  getTranslations()  → async, returns t                  │
│                                                         │
│  Can call Intl directly.                                │
│  Can pass translated strings as props.                  │
│                                                         │
│  ─────────────────────────────────────────────────────  │
│                                                         │
│  CLIENT COMPONENT                                       │
│                                                         │
│  useTranslations()  → requires NextIntlClientProvider  │
│                                                         │
│  Needs messages passed from the server.                 │
│  Prefer receiving translated props when possible.       │
└─────────────────────────────────────────────────────────┘

Intl Formatting Examples

┌─────────────────────────────────────────────────────────┐
│  LOCALE   DATE                CURRENCY                  │
│                                                         │
│  en-US    October 12, 2025    $1,234.56                 │
│  de-DE    12. Oktober 2025    1.234,56 €                │
│  fr-FR    12 octobre 2025     1 234,56 €                │
│  ja-JP    2025年10月12日        ¥1,234                  │
│                                                         │
│  Same data, different formats.                          │
│  Intl handles all of it.                                │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Native formattingIntl.DateTimeFormat, Intl.NumberFormat
Client libraryreact-i18next + i18next
App Router librarynext-intl
Server hookuseTranslations (sync)
Async servergetTranslations
Client providerNextIntlClientProvider
Locale routing[locale] segment
Relative timeIntl.RelativeTimeFormat
PluralsIntl.PluralRules, library plurals
DetectionBrowser header, cookie, middleware

Key takeaways:

  • Intl is the formatting foundation. Dates, numbers, currency, and relative time are formatted by the browser, not by a translation library. Use dateStyle, currency, and numeric: 'auto' instead of manual formatting .
  • Translation libraries handle string lookup, not formatting. react-i18next and next-intl manage keys, locales, and plurals. They delegate formatting to Intl.
  • The rendering model determines the library. Client-rendered React uses react-i18next with useTranslation. Next.js App Router uses next-intl with useTranslations for sync components and getTranslations for async components .
  • Server Components cannot use hooks. The hook-based API requires a client boundary. Async Server Components use the awaitable API instead.
  • Pass translated labels as props when possible. Sending all messages to Client Components increases the bundle. A Server Component that passes a translated string as a prop is more efficient than a Client Component that looks up the string itself.
  • The locale routing choice precedes the library choice. Path prefixes, subdomains, and cookies each have different SEO and caching implications. The [locale] segment is the Next.js convention for prefix-based routing .
  • numeric: 'auto' produces natural relative times. “Yesterday” is better than “1 day ago” when the locale has the word. The Intl.RelativeTimeFormat option handles the fallback.
  • Translation management is part of the ecosystem. i18next has CLI tools and commercial services for extracting, managing, and translating keys. The library choice is partly an ecosystem choice .
  • Testing with pseudo-localization catches untranslated strings. A pseudo-locale that expands and accents text reveals strings that were never wrapped in a translation call, because they appear unchanged while everything else is transformed.
  • i18n is architecture, not a feature. The decision to use path prefixes, the choice of library, and the server-client split all affect how the application is structured. Retrofitting i18n into an application that hardcoded English strings is harder than designing for it from the start.

Remember: Internationalization in React is two problems that look like one. The formatting problem is solved by Intl, which is built into every modern JavaScript runtime and should never be replaced by a library. The translation problem is solved by a library, and the right library depends on the rendering model: react-i18next for client-rendered applications, next-intl for the App Router with Server Components. The architecture—where the locale lives, how it is detected, and what gets sent to the client—determines whether the application is fast, SEO-friendly, and maintainable. Start with Intl, choose the library that matches the framework, and design the routing before the first string is translated.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!