Setting up i18n framework (react-intl) for web application
React Intl is part of FormatJS project. Difference from i18next: react-intl strictly follows ICU Message Format standard, which gives powerful syntax for complex cases: pluralization, selection by value, date/number/currency formatting — all through unified standard without custom helpers.
Installation
npm install react-intl
For TypeScript — types are included in the package.
Initialization
// i18n/messages/ru.ts
export const ru = {
'nav.catalog': 'Каталог',
'nav.cart': '{count, plural, one {Корзина ({count} товар)} few {Корзина ({count} товара)} many {Корзина ({count} товаров)} other {Корзина ({count} товаров)}}',
'product.price': '{price, number, ::currency/RUB}',
'product.added': 'Товар добавлен {date, date, medium}',
'order.status': '{status, select, pending {Ожидает} paid {Оплачен} cancelled {Отменён} other {Неизвестно}}',
'welcome': 'Добро пожаловать, {name}!',
}
// i18n/messages/en.ts
export const en = {
'nav.catalog': 'Catalog',
'nav.cart': '{count, plural, one {Cart ({count} item)} other {Cart ({count} items)}}',
'product.price': '{price, number, ::currency/USD}',
'product.added': 'Product added {date, date, medium}',
'order.status': '{status, select, pending {Pending} paid {Paid} cancelled {Cancelled} other {Unknown}}',
'welcome': 'Welcome, {name}!',
}
// App.tsx
import { IntlProvider } from 'react-intl'
import { ru } from '@/i18n/messages/ru'
import { en } from '@/i18n/messages/en'
const messages = { ru, en }
function App({ locale = 'ru' }: { locale: string }) {
return (
<IntlProvider
locale={locale}
messages={messages[locale as keyof typeof messages]}
defaultLocale="ru"
onError={(err) => {
// Don't fail on missing translations in dev
if (err.code !== 'MISSING_TRANSLATION') throw err
}}
>
<Router />
</IntlProvider>
)
}
Using in components
import { FormattedMessage, FormattedNumber, FormattedDate, useIntl } from 'react-intl'
// Simple text
function Greeting({ name }: { name: string }) {
return <FormattedMessage id="welcome" values={{ name }} />
}
// Pluralization
function CartIcon({ count }: { count: number }) {
return (
<span>
<FormattedMessage id="nav.cart" values={{ count }} />
</span>
)
// count=1: "Корзина (1 товар)"
// count=5: "Корзина (5 товаров)"
}
// Numbers and currency
function Price({ value }: { value: number }) {
return (
<FormattedNumber
value={value}
style="currency"
currency="RUB"
maximumFractionDigits={0}
/>
)
// ru: "14 990 ₽"
}
// Date
function ProductDate({ date }: { date: Date }) {
return <FormattedDate value={date} year="numeric" month="long" day="numeric" />
// ru: "28 марта 2026 г."
}
// select — statuses, roles, categories
function OrderStatus({ status }: { status: string }) {
return <FormattedMessage id="order.status" values={{ status }} />
}
useIntl hook for imperative usage
function SearchInput() {
const intl = useIntl()
return (
<input
type="search"
placeholder={intl.formatMessage({ id: 'search.placeholder' })}
aria-label={intl.formatMessage({ id: 'search.label' })}
/>
)
}
defineMessages: type-safe messaging
import { defineMessages, useIntl } from 'react-intl'
// Declare messages as constants — IDE autocomplete and TypeScript find errors
const messages = defineMessages({
title: {
id: 'catalog.title',
defaultMessage: 'Каталог товаров',
description: 'Catalog page title',
},
empty: {
id: 'catalog.empty',
defaultMessage: 'Товары не найдены',
},
})
function CatalogPage() {
const intl = useIntl()
return <h1>{intl.formatMessage(messages.title)}</h1>
}
Async message loading
// Messages — separate chunks for each language
async function loadMessages(locale: string): Promise<Record<string, string>> {
switch (locale) {
case 'ru': return (await import('@/i18n/messages/ru')).ru
case 'en': return (await import('@/i18n/messages/en')).en
case 'de': return (await import('@/i18n/messages/de')).de
default: return (await import('@/i18n/messages/ru')).ru
}
}
// In component
function LocalizedApp({ locale }: { locale: string }) {
const [messages, setMessages] = useState<Record<string, string> | null>(null)
useEffect(() => {
loadMessages(locale).then(setMessages)
}, [locale])
if (!messages) return <PageLoader />
return (
<IntlProvider locale={locale} messages={messages}>
<App />
</IntlProvider>
)
}
Extracting messages for translators
FormatJS provides CLI for automatically collecting all ids from code:
npx @formatjs/cli-lib extract \
'src/**/*.{ts,tsx}' \
--out-file src/i18n/extracted.json \
--id-interpolation-pattern '[sha512:contenthash:base64:6]'
Pass resulting JSON to translator or upload to Crowdin / Phrase.
Relative time
import { FormattedRelativeTime } from 'react-intl'
function TimeAgo({ timestamp }: { timestamp: number }) {
const diff = Math.round((timestamp - Date.now()) / 1000) // seconds
return (
<FormattedRelativeTime
value={diff}
unit="second"
updateIntervalInSeconds={30} // auto-update
/>
)
// "-5 minutes ago", "yesterday", "3 days ago"
}
Timeframe
Installation, IntlProvider setup, basic UI translation (100–200 strings) for 2 languages — 1–2 days. With async message loading, CLI extraction setup and CI completeness checks — 3 days.







