Back to Blog
i18nInternationalizationLocalizationGlobal

Internationalization (i18n): Building Apps for a Global Audience

Implement internationalization the right way. From text translation to date formatting to RTL support, make your app work everywhere.

B
Bootspring Team
Engineering
August 15, 2025
6 min read

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 rendering

Integration 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.

Share this article

Help spread the word about Bootspring