Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 7 min read

Internationalization in React: i18n with react-i18next and Next.js

A practical guide to internationalizing React applications — setting up translations, handling plurals, formatting dates and numbers, and implementing language switching.

By codefunc

i18nreactinternationalizationlocalizationnextjsfrontend

Internationalization (i18n) prepares your app for multiple languages. Localization (l10n) adapts it for specific locales. Even if you're launching in one language, building with i18n from the start is easier than retrofitting later. React has mature tools for this — react-i18next is the standard choice.

Bottom line: Use react-i18next for React apps, next-intl for Next.js App Router. Extract all user-facing strings to translation files. Handle plurals, interpolation, and context properly. Format dates and numbers with the Intl API. Store language preference and sync with URL for SEO. Plan for text expansion — German is ~30% longer than English.

Setup with react-i18next

npm install i18next react-i18next i18next-browser-languagedetector

Configuration

// lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

import en from '../locales/en/translation.json';
import es from '../locales/es/translation.json';
import de from '../locales/de/translation.json';

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: en },
      es: { translation: es },
      de: { translation: de },
    },
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false, // React already escapes
    },
    detection: {
      order: ['localStorage', 'navigator'],
      caches: ['localStorage'],
    },
  });

export default i18n;

Translation files

// locales/en/translation.json
{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "loading": "Loading..."
  },
  "auth": {
    "login": "Log in",
    "logout": "Log out",
    "signUp": "Sign up",
    "forgotPassword": "Forgot password?"
  },
  "dashboard": {
    "welcome": "Welcome, {{name}}!",
    "lastLogin": "Last login: {{date}}"
  }
}
// locales/es/translation.json
{
  "common": {
    "save": "Guardar",
    "cancel": "Cancelar",
    "delete": "Eliminar",
    "loading": "Cargando..."
  },
  "auth": {
    "login": "Iniciar sesión",
    "logout": "Cerrar sesión",
    "signUp": "Registrarse",
    "forgotPassword": "¿Olvidaste tu contraseña?"
  },
  "dashboard": {
    "welcome": "¡Bienvenido, {{name}}!",
    "lastLogin": "Último acceso: {{date}}"
  }
}

Initialize in app

// app/layout.tsx or main.tsx
import '@/lib/i18n';

// In React
import { I18nextProvider } from 'react-i18next';
import i18n from '@/lib/i18n';

function App({ children }) {
  return (
    <I18nextProvider i18n={i18n}>
      {children}
    </I18nextProvider>
  );
}

Using translations

useTranslation hook

import { useTranslation } from 'react-i18next';

function Dashboard() {
  const { t } = useTranslation();

  return (
    <div>
      <h1>{t('dashboard.welcome', { name: 'Ada' })}</h1>
      <button>{t('common.save')}</button>
      <button>{t('common.cancel')}</button>
    </div>
  );
}

Trans component

For translations with JSX:

import { Trans } from 'react-i18next';

function Terms() {
  return (
    <p>
      <Trans i18nKey="terms.agreement">
        By signing up, you agree to our <a href="/terms">Terms</a> and{' '}
        <a href="/privacy">Privacy Policy</a>.
      </Trans>
    </p>
  );
}
{
  "terms": {
    "agreement": "By signing up, you agree to our <0>Terms</0> and <1>Privacy Policy</1>."
  }
}

Pluralization

English

{
  "items": {
    "count_one": "{{count}} item",
    "count_other": "{{count}} items"
  },
  "messages": {
    "unread_zero": "No unread messages",
    "unread_one": "1 unread message",
    "unread_other": "{{count}} unread messages"
  }
}
t('items.count', { count: 1 });  // "1 item"
t('items.count', { count: 5 });  // "5 items"
t('messages.unread', { count: 0 });  // "No unread messages"

Complex plurals (Russian, Arabic)

Some languages have multiple plural forms:

// locales/ru/translation.json
{
  "items": {
    "count_one": "{{count}} предмет",
    "count_few": "{{count}} предмета",
    "count_many": "{{count}} предметов"
  }
}

Interpolation

Basic interpolation

{
  "greeting": "Hello, {{name}}!",
  "orderStatus": "Your order #{{orderId}} is {{status}}"
}
t('greeting', { name: 'Ada' });
t('orderStatus', { orderId: '12345', status: 'shipped' });

Formatting

{
  "price": "Price: {{value, currency}}",
  "date": "Date: {{date, datetime}}"
}
// Configure formatters in i18n.init()
i18n.init({
  interpolation: {
    format: (value, format, lng) => {
      if (format === 'currency') {
        return new Intl.NumberFormat(lng, {
          style: 'currency',
          currency: 'USD',
        }).format(value);
      }
      if (format === 'datetime') {
        return new Intl.DateTimeFormat(lng).format(value);
      }
      return value;
    },
  },
});

Context

Different translations based on context:

{
  "friend": "A friend",
  "friend_male": "A boyfriend",
  "friend_female": "A girlfriend"
}
t('friend', { context: 'male' });  // "A boyfriend"
t('friend', { context: 'female' });  // "A girlfriend"

Language switching

import { useTranslation } from 'react-i18next';

function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const languages = [
    { code: 'en', name: 'English' },
    { code: 'es', name: 'Español' },
    { code: 'de', name: 'Deutsch' },
  ];

  return (
    <select
      value={i18n.language}
      onChange={(e) => i18n.changeLanguage(e.target.value)}
    >
      {languages.map((lang) => (
        <option key={lang.code} value={lang.code}>
          {lang.name}
        </option>
      ))}
    </select>
  );
}

Next.js App Router with next-intl

next-intl is designed for Next.js App Router.

npm install next-intl

Setup

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

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default,
}));
// middleware.ts
import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['en', 'es', 'de'],
  defaultLocale: 'en',
});

export const config = {
  matcher: ['/', '/(en|es|de)/:path*'],
};

Folder structure

app/
  [locale]/
    layout.tsx
    page.tsx
    about/
      page.tsx
messages/
  en.json
  es.json
  de.json

Layout with locale

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  const messages = await getMessages();

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

Using translations

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

export default async function HomePage() {
  const t = await getTranslations('home');
  
  return <h1>{t('title')}</h1>;
}

// Client Component
'use client';
import { useTranslations } from 'next-intl';

export default function Button() {
  const t = useTranslations('common');
  
  return <button>{t('save')}</button>;
}

Language switcher with URL

'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';

export default function LanguageSwitcher() {
  const locale = useLocale();
  const pathname = usePathname();
  const router = useRouter();

  const switchLocale = (newLocale: string) => {
    const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
    router.push(newPath);
  };

  return (
    <select value={locale} onChange={(e) => switchLocale(e.target.value)}>
      <option value="en">English</option>
      <option value="es">Español</option>
      <option value="de">Deutsch</option>
    </select>
  );
}

Date and number formatting

Use the Intl API for locale-aware formatting:

Dates

function FormattedDate({ date, locale }) {
  const formatted = new Intl.DateTimeFormat(locale, {
    dateStyle: 'long',
    timeStyle: 'short',
  }).format(date);

  return <time dateTime={date.toISOString()}>{formatted}</time>;
}

// "July 7, 2026 at 2:30 PM" (en-US)
// "7 de julio de 2026, 14:30" (es-ES)
// "7. Juli 2026 um 14:30" (de-DE)

Numbers

function FormattedNumber({ value, locale }) {
  const formatted = new Intl.NumberFormat(locale).format(value);
  return <span>{formatted}</span>;
}

// 1,234,567.89 (en-US)
// 1.234.567,89 (de-DE)
// 1 234 567,89 (fr-FR)

Currency

function Price({ amount, currency, locale }) {
  const formatted = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);

  return <span>{formatted}</span>;
}

// $1,234.56 (en-US, USD)
// 1.234,56 € (de-DE, EUR)
// £1,234.56 (en-GB, GBP)

Relative time

function TimeAgo({ date, locale }) {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diff = (date.getTime() - Date.now()) / 1000;
  
  if (Math.abs(diff) < 60) return rtf.format(Math.round(diff), 'second');
  if (Math.abs(diff) < 3600) return rtf.format(Math.round(diff / 60), 'minute');
  if (Math.abs(diff) < 86400) return rtf.format(Math.round(diff / 3600), 'hour');
  return rtf.format(Math.round(diff / 86400), 'day');
}

// "2 hours ago" (en)
// "hace 2 horas" (es)
// "vor 2 Stunden" (de)

Best practices

Key naming

// Good: hierarchical, descriptive
{
  "pages": {
    "home": {
      "hero": {
        "title": "Welcome to Our App",
        "subtitle": "The best solution for..."
      }
    }
  },
  "components": {
    "button": {
      "submit": "Submit",
      "cancel": "Cancel"
    }
  }
}

// Avoid: flat, unclear
{
  "title1": "Welcome",
  "btn1": "Submit"
}

Namespace separation

Split translations by feature:

i18n.init({
  ns: ['common', 'auth', 'dashboard', 'settings'],
  defaultNS: 'common',
});
const { t } = useTranslation('auth');
t('login'); // Uses auth namespace

Handle missing translations

i18n.init({
  saveMissing: process.env.NODE_ENV === 'development',
  missingKeyHandler: (lng, ns, key) => {
    console.warn(`Missing translation: ${lng}/${ns}/${key}`);
  },
});

Text expansion

German text is ~30% longer than English. Design UI to accommodate:

/* Avoid fixed widths */
.button {
  padding: 0.5rem 1rem;
  white-space: nowrap;
  /* NOT: width: 100px; */
}

Translation management

Compare translation files

Use the Text Diff tool to compare translation files and find missing keys. Validate JSON syntax with JSON Validator. Format messy translation files with JSON Formatter.

Extract keys automatically

npm install -D i18next-parser
// i18next-parser.config.js
module.exports = {
  locales: ['en', 'es', 'de'],
  output: 'locales/$LOCALE/$NAMESPACE.json',
  input: ['src/**/*.{ts,tsx}'],
  keySeparator: '.',
  namespaceSeparator: ':',
};

SEO for multilingual sites

// In <head>
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="es" href="https://example.com/es/about" />
<link rel="alternate" hreflang="de" href="https://example.com/de/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />

HTML lang attribute

<html lang={locale}>

Sitemap with alternates

<url>
  <loc>https://example.com/en/about</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about"/>
  <xhtml:link rel="alternate" hreflang="es" href="https://example.com/es/about"/>
  <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/about"/>
</url>

Practical takeaway

Start with i18n structure even for single-language apps — it's much harder to add later. Use react-i18next for client-side React, next-intl for Next.js App Router. Organize translations hierarchically by feature. Handle plurals properly for each language. Use Intl API for dates and numbers. Put locale in URL for SEO. Plan UI for text expansion. Automate key extraction to keep translations in sync.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool