Building for a global audience requires more than translating strings. Dates, numbers, currencies, pluralization, and text direction all vary by locale. Proper internationalization (i18n) makes these differences feel natural to users everywhere.
Core Concepts#
i18n vs L10n#
Internationalization (i18n): Building software to support multiple locales Localization (L10n): Adapting content for specific locales
Locale Identifiers#
Format: language-REGION
Examples:
en-US (English, United States)
en-GB (English, United Kingdom)
es-ES (Spanish, Spain)
es-MX (Spanish, Mexico)
zh-CN (Chinese, Simplified)
zh-TW (Chinese, Traditional)
String Translation#
Using react-i18next#
1// i18n/config.ts
2import i18n from 'i18next';
3import { initReactI18next } from 'react-i18next';
4import Backend from 'i18next-http-backend';
5import LanguageDetector from 'i18next-browser-languagedetector';
6
7i18n
8 .use(Backend)
9 .use(LanguageDetector)
10 .use(initReactI18next)
11 .init({
12 fallbackLng: 'en',
13 supportedLngs: ['en', 'es', 'fr', 'de', 'ja'],
14 interpolation: {
15 escapeValue: false,
16 },
17 backend: {
18 loadPath: '/locales/{{lng}}/{{ns}}.json',
19 },
20 });
21
22export default i18n;Translation Files#
1// locales/en/common.json
2{
3 "greeting": "Hello, {{name}}!",
4 "items": {
5 "one": "{{count}} item",
6 "other": "{{count}} items"
7 },
8 "nav": {
9 "home": "Home",
10 "about": "About",
11 "contact": "Contact"
12 }
13}
14
15// locales/es/common.json
16{
17 "greeting": "¡Hola, {{name}}!",
18 "items": {
19 "one": "{{count}} artículo",
20 "other": "{{count}} artículos"
21 },
22 "nav": {
23 "home": "Inicio",
24 "about": "Acerca de",
25 "contact": "Contacto"
26 }
27}Usage in Components#
1import { useTranslation } from 'react-i18next';
2
3function Welcome({ name, itemCount }) {
4 const { t } = useTranslation();
5
6 return (
7 <div>
8 <h1>{t('greeting', { name })}</h1>
9 <p>{t('items', { count: itemCount })}</p>
10 </div>
11 );
12}Pluralization#
Complex Pluralization Rules#
1// Russian has different rules: one, few, many, other
2// locales/ru/common.json
3{
4 "items": {
5 "one": "{{count}} товар", // 1, 21, 31...
6 "few": "{{count}} товара", // 2-4, 22-24...
7 "many": "{{count}} товаров", // 5-20, 25-30...
8 "other": "{{count}} товара" // decimals
9 }
10}Using ICU Message Format#
1// More powerful pluralization with format-js
2import { IntlProvider, FormattedMessage } from 'react-intl';
3
4const messages = {
5 items: `{count, plural,
6 =0 {No items}
7 one {# item}
8 other {# items}
9 }`,
10 gender: `{gender, select,
11 male {He}
12 female {She}
13 other {They}
14 } liked your post.`
15};
16
17<FormattedMessage
18 id="items"
19 values={{ count: 5 }}
20/>Date and Time#
Using Intl.DateTimeFormat#
1function formatDate(date: Date, locale: string) {
2 return new Intl.DateTimeFormat(locale, {
3 year: 'numeric',
4 month: 'long',
5 day: 'numeric',
6 }).format(date);
7}
8
9formatDate(new Date(), 'en-US'); // "January 15, 2024"
10formatDate(new Date(), 'de-DE'); // "15. Januar 2024"
11formatDate(new Date(), 'ja-JP'); // "2024年1月15日"Relative Time#
1function formatRelativeTime(date: Date, locale: string) {
2 const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
3 const diff = date.getTime() - Date.now();
4 const days = Math.round(diff / (1000 * 60 * 60 * 24));
5
6 if (Math.abs(days) < 1) {
7 const hours = Math.round(diff / (1000 * 60 * 60));
8 return rtf.format(hours, 'hour');
9 }
10 return rtf.format(days, 'day');
11}
12
13formatRelativeTime(yesterday, 'en'); // "yesterday"
14formatRelativeTime(yesterday, 'es'); // "ayer"Numbers and Currency#
Number Formatting#
1function formatNumber(num: number, locale: string) {
2 return new Intl.NumberFormat(locale).format(num);
3}
4
5formatNumber(1234567.89, 'en-US'); // "1,234,567.89"
6formatNumber(1234567.89, 'de-DE'); // "1.234.567,89"
7formatNumber(1234567.89, 'fr-FR'); // "1 234 567,89"Currency Formatting#
1function formatCurrency(amount: number, currency: string, locale: string) {
2 return new Intl.NumberFormat(locale, {
3 style: 'currency',
4 currency: currency,
5 }).format(amount);
6}
7
8formatCurrency(99.99, 'USD', 'en-US'); // "$99.99"
9formatCurrency(99.99, 'EUR', 'de-DE'); // "99,99 €"
10formatCurrency(99.99, 'JPY', 'ja-JP'); // "¥100"Percentage and Units#
1// Percentage
2new Intl.NumberFormat('en', { style: 'percent' }).format(0.25); // "25%"
3
4// Units
5new Intl.NumberFormat('en', {
6 style: 'unit',
7 unit: 'kilometer-per-hour',
8}).format(100); // "100 km/h"RTL Support#
CSS Logical Properties#
1/* Physical (doesn't flip) */
2.old-way {
3 margin-left: 1rem;
4 padding-right: 2rem;
5 text-align: left;
6}
7
8/* Logical (flips for RTL) */
9.modern-way {
10 margin-inline-start: 1rem;
11 padding-inline-end: 2rem;
12 text-align: start;
13}
14
15/* Direction-aware flexbox */
16.container {
17 display: flex;
18 flex-direction: row; /* Respects dir attribute */
19}Document Direction#
1function App() {
2 const { i18n } = useTranslation();
3 const dir = ['ar', 'he', 'fa'].includes(i18n.language) ? 'rtl' : 'ltr';
4
5 return (
6 <html lang={i18n.language} dir={dir}>
7 <body>{/* content */}</body>
8 </html>
9 );
10}Bidirectional Text#
<!-- Isolate embedded opposite-direction text -->
<p>The title is "<bdi>مرحبا</bdi>" in Arabic.</p>
<!-- Force direction -->
<span dir="ltr">+1 (555) 123-4567</span>Content Management#
Translation Workflow#
1. Extract strings from code
$ i18next-scanner
2. Send to translation service
- Lokalise, Phrase, Crowdin
3. Translators work on strings
4. Import translations back
5. Review in context
6. Deploy
String Extraction#
1// i18next-scanner.config.js
2module.exports = {
3 input: ['src/**/*.{js,jsx,ts,tsx}'],
4 output: './locales',
5 options: {
6 lngs: ['en', 'es', 'fr'],
7 defaultLng: 'en',
8 func: {
9 list: ['t', 'i18next.t'],
10 },
11 trans: {
12 component: 'Trans',
13 },
14 },
15};Testing i18n#
Pseudo-Localization#
1// Transform strings to catch issues
2function pseudoLocalize(str) {
3 const chars = {
4 a: 'α', e: 'ε', i: 'ι', o: 'ο', u: 'μ',
5 A: 'Λ', E: 'Σ', I: 'Ι', O: 'Ω', U: 'Ц',
6 };
7
8 return '[' + str.replace(/[aeiouAEIOU]/g, c => chars[c]) + ']';
9}
10
11// "Hello World" → "[Hεllο Wοrld]"
12// Brackets show where text is cut off
13// Accents test renderingIntegration Tests#
1describe('i18n', () => {
2 it('renders in different languages', () => {
3 const { rerender } = render(
4 <I18nextProvider i18n={i18n}>
5 <Welcome name="John" />
6 </I18nextProvider>
7 );
8
9 expect(screen.getByText(/Hello, John/)).toBeInTheDocument();
10
11 i18n.changeLanguage('es');
12 rerender(/* same */);
13
14 expect(screen.getByText(/Hola, John/)).toBeInTheDocument();
15 });
16});Best Practices#
String Guidelines#
1// ❌ Concatenation (word order varies)
2t('welcome') + ' ' + name + '!'
3
4// ✅ Interpolation
5t('welcome', { name })
6
7// ❌ Hardcoded text in JSX
8<button>Submit</button>
9
10// ✅ Translated text
11<button>{t('submit')}</button>
12
13// ❌ Sentence fragments
14t('there_are') + ' ' + count + ' ' + t('items')
15
16// ✅ Complete sentences with pluralization
17t('items', { count })Key Naming#
1{
2 "nav.home": "Home",
3 "nav.about": "About",
4 "button.submit": "Submit",
5 "button.cancel": "Cancel",
6 "error.required": "This field is required",
7 "error.email": "Please enter a valid email"
8}Conclusion#
Internationalization is an investment that pays off as your user base grows globally. Build i18n into your architecture from the start—retrofitting is painful and error-prone.
Use established libraries, follow locale-aware formatting standards, and test with pseudo-localization. Your global users will appreciate an app that speaks their language naturally.