Internationalization (i18n) is all about making your app ready for the real world: a world where people don’t all use the same language, the same date formats, the same currency rules, or even the same reading direction. React i18n is simply the process of bringing that flexibility into a React application. Instead of hard-coding English strings everywhere and hoping for the best, a properly internationalized app separates content from code, handles plurals and formatting correctly, respects the user’s locale, and behaves like it was actually built for them.
This guide shows you exactly how to do that with react-intl, one of the most stable and feature-rich libraries in the React ecosystem. No filler, no outdated patterns, just practical, modern techniques.
Here’s what we’ll cover:
Which React i18n libraries exist today and what makes react-intl a strong choice
How to set up a clean, modern React project (Vite-based) ready for localization
How to organize translation files and keep your message structure maintainable
How to translate simple text, titles, and paragraphs
How to handle plurals using ICU MessageFormat
How to localize dates, times, numbers, and currencies correctly per locale
How to render gender-specific text (he/she/they) without messy branching in components
How to build a language switcher, including auto-detecting the user’s preferred locale
How to support RTL languages like Arabic
How to lazy-load translation bundles so you don’t ship all languages at once
How to persist the user’s chosen locale so it survives page reloads
By the end of this guide, you’ll have a fully localized React app that:
loads only one locale on startup,
switches languages instantly,
formats text and data properly for each region, and
keeps your components clean and your translators happy.
Localization (aka l10n) isn't just "translate your app and ship it". So basically, what is localization? It's the process of adapting your product so it actually feels native to users in a specific region. That includes not only language but also formatting rules, cultural expectations, and sometimes even UX or logic adjustments that a new market requires.
Here's what localization usually covers:
number formats
date and time formats
currency
plural rules
keyboard layouts
sorting/collation
text direction (LTR/RTL)
UI colors, icons, and design patterns
Translation swaps words; localization makes the whole app behave like it was built for those users from day one.
Prerequisites for React i18n (what you need before starting)
This guide assumes you're already running the latest stable Node.js and npm. You should also be comfortable with basic JavaScript, HTML, and simple npm/Yarn commands. Nothing hardcore, just enough to move around a React project.
How to set up a modern React project for react-intl with Vite
Before you start adding react-intl or any i18n logic, you need a clean React setup that isn't stuck in the 'yer olde days' era of tooling. Nowadays, the go-to way to spin up a fast, lightweight React project is Vite. It's quick, minimal, and feels exactly like something a sane developer would want to work with.
Step 1: Create a new React project with Vite
Run the official Vite initializer:
npm create vite@latest react-intl-lokalise
If you prefer Yarn or pnpm:
yarn create vite react-intl-lokalisepnpm create vite react-intl-lokalise
When asked by the wizard, choose "React" as a framework and "TypeScript" as a language.
Step 2: Install dependencies
Move into your project folder and install everything:
cd react-intl-lokalisenpm install
Step 3: Start the development server
Fire it up:
npm run dev
Now you can navigate to localhost:5173 and observe your app. If you see the cute counter button doing its thing, congrats — the project is alive and ready for react-intl.
Popular React i18n libraries (and how to choose the right one)
When you start adding internationalization to a React app, one of the first questions you'll hit is: which i18n library should I use? There are several solid options, and they all solve similar problems in slightly different ways. Picking the right one isn't always obvious, so here are a few things worth considering before you commit.
What to check before choosing a React i18n library
Choosing an i18n library isn’t just “install whatever is trending on npm.” It sits at the center of your UI, your build pipeline, your translators’ workflow, and your entire long-term product strategy. Before you commit, make sure the library fits your real needs, not just the demo app.
Here are some factors to consider:
1. Does it fit your current stack and architecture?
Different frameworks have different needs:
SSR / SSG (Next.js, Remix)
client-only apps (Vite, CRA replacements)
microfrontends
hybrid rendering
2. Does it support the level of linguistic complexity you need?
Once again, think about the real product, not the “hello world” version:
If your product spans multiple markets, these capabilities are typically non-negotiable.
3. Is the API comfortable for your team?
The best library is the one your team actually enjoys using:
Are developers comfortable with message IDs and ICU syntax?
Do designers/translators understand the file structure?
Does onboarding a new engineer require reading a 40-page manual?
4. Documentation, ecosystem, and maintenance
A good i18n library must be alive and healthy:
clear, up-to-date docs
active maintainers
regular releases
stable API
compatible with modern React patterns and bundlers
5. Does it integrate smoothly with your translation workflow?
Especially important if you use a TMS:
clean JSON/ICU export/import
predictable key naming
supports context notes
doesn’t break translators with weird nesting or encoded placeholders
6. Performance and bundle size
Some libraries:
ship large runtimes
load entire locale bundles upfront
don’t support code splitting
7. Future-proofing
Look at how the library interacts with the platform:
Does it build around the native Intl API?
Does it embrace modern React?
Does it adapt quickly to new browser features?
React-intl (FormatJS): The classic, stable choice
React-intl is part of the FormatJS ecosystem and supports 150+ languages out of the box. It's widely used, battle-tested, and plays nicely with standard locale features like numbers, dates, times, and currency. Because it's built on top of the native JavaScript Intl API, it automatically leans on browser-level features when possible, and you get polyfills for older environments that don't support Intl.
React-intl uses React context under the hood and provides components and hooks that make it easy to load and switch locales dynamically. The API is clean, predictable, and comes with extensive docs (yeah, seriously good docs). In this guide, we're using react-intl because it's still one of the best all-around choices for React i18n.
React-i18next is built on top of the broader i18next ecosystem and is extremely flexible. It handles automatic re-rendering when the user switches languages, supports backend loaders, caching, language detection, and even packs translation files with your bundler. That plugin system is the library's superpower: if there's something you want to automate, there's probably an i18next plugin for it.
It also offers experimental React Suspense support, which can simplify async translation loading in modern apps. If you're interested in learning more, we have a dedicated tutorial on React with i18next.
LinguiJS: Lightweight and developer-friendly
LinguiJS is newer compared to the big players, but it's built with developer ergonomics in mind. It focuses on simplicity and minimal overhead while still giving you ICU message formatting, pluralization, extraction tools, and good integration with modern bundlers. If you want something clean and code-driven without too much ceremony, LinguiJS is worth a look.
Getting started with React-intl
How to install react-intl
First, add react-intl to your React + Vite project:
npm install react-intl
or with Yarn:
yarn add react-intl
That's all you need on the dependency side for now.
Where should you store your translation files in a React app?
Before we write any translated text, we need a clear place for our messages. For this tutorial, we will support three locales:
English (en)
French (fr)
Arabic (ar)
A simple structure is (later I'll also explain how to make it more scalable):
This setup keeps all i18n-related files under src/i18n/messages, which makes it easier to:
quickly see which locales your app supports
add or remove languages without touching unrelated code
later switch to dynamic imports (code splitting) if your message files grow large
Each *.json file will be a flat key → value map, and we will keep keys consistent across locales.
For now, our demo messages will be simple: a title and a short description.
Example: src/i18n/messages/en.json
{ "app.title": "React-intl demo", "app.description": "This is a small demo showing how to localize a React app with react-intl."}
Example: src/i18n/messages/fr.json
{ "app.title": "Démo React-intl", "app.description": "Cet exemple montre comment localiser une application React avec react-intl."}
Example: src/i18n/messages/ar.json
{ "app.title": "عرض React-intl", "app.description": "هذا مثال بسيط يوضح كيفية تعريب تطبيق React باستخدام react-intl."}
We will extend these files with more complex messages (plurals, dates, gender, etc.) later, but for now we only need these two keys.
How to plug react-intl into a Vite + React app
To make translations work, we have to wrap our React tree in IntlProvider and pass it the current locale plus the right set of messages.
In a Vite + React setup, the best place to do this is your entry file, usually src/main.tsx. Replace the contents of src/main.tsx with something like this:
// src/main.tsximport { StrictMode } from 'react'import { createRoot } from 'react-dom/client'import { IntlProvider } from 'react-intl'import App from './App'import './index.css'import enMessages from './i18n/messages/en.json'import frMessages from './i18n/messages/fr.json'import arMessages from './i18n/messages/ar.json'const messagesMap: Record<string, Record<string, string>> = { en: enMessages, fr: frMessages, ar: arMessages}// For now, we hard-code the current locale.// Later, we can wire this to user preference or browser settings.const locale = 'en'const messages = messagesMap[locale]createRoot(document.getElementById('root') as HTMLElement).render( <StrictMode> <IntlProvider locale={locale} messages={messages}> <App /> </IntlProvider> </StrictMode>)
At this point, App and all components below can use react-intl to render translated text.
How to translate simple text with react-intl (heading and paragraph)
Now we will replace the default Vite starter UI with a minimal page that uses two translated messages:
a heading (app.title)
a short paragraph (app.description)
We will use FormattedMessage, the simplest way to render a message by ID. Replace the contents of src/App.tsx with:
FormattedMessage looks up the id in the messages object we passed to IntlProvider.
If the current locale is 'en', it will use en.json. If you change locale to 'fr' or 'ar' in main.tsx, the same components will automatically render French or Arabic text.
At this point, you already have a working, localized React app with:
a place for translations
three locales wired in
a simple example of translated UI using react-intl
How to handle plurals in React with react-intl
Pluralization is one of those things that looks easy in English and then completely falls apart once you add more languages. If you try to do it manually with "message" + (count === 1 ? '' : 's'), it will break the moment you add French, Arabic, or pretty much anything that isn’t English.
React-intl solves this with ICU MessageFormat, which knows about plural rules per locale and lets translators control the sentence, not your business logic.
Step 1: Add a pluralized message to your translation files
Let’s add a message that shows how many notifications a user has. We’ll use the same message ID in all locales: app.notifications.
src/i18n/messages/en.json
{ "app.notifications": "{count, plural, =0 {You have no new notifications} one {You have # new notification} other {You have # new notifications}}"}
(I'm omitting already existing keys.)
What’s going on in app.notifications:
count is the variable we pass from React.
plural tells ICU this is a pluralized message.
=0, one, other are plural categories:
=0 — explicit zero case (nice UX).
one — used when the locale thinks the number is “one”.
other — fallback for all other counts.
# inside text is replaced with the numeric value of count.
Now we add equivalents for French and Arabic. They don’t have to be perfect translations for this tutorial, the important part is the structure.
src/i18n/messages/fr.json
{ "app.notifications": "{count, plural, =0 {Vous n'avez aucune nouvelle notification} one {Vous avez # nouvelle notification} other {Vous avez # nouvelles notifications}}"}
src/i18n/messages/ar.json
For Arabic, real plural rules are more complex (zero, one, two, few, many, other), but for a demo we can keep it simple and still use plural:
{ "app.notifications": "{count, plural, =0 {لا توجد إشعارات جديدة} one {لديك إشعار جديد واحد} other {لديك # إشعارات جديدة}}"}
Later, if you want to be precise, you can extend it with two, few, many categories and let your translators own that. You can also take advantage of Lokalise and hire professional translators or utilize our AI.
Step 2: Render the plural message from React
Now we need a place in the UI that shows this message and lets us play with different values of count. We’ll add a small counter in App.tsx.
id="app.notifications" → connects to the message in your JSON.
values={{ count }} → injects count into the ICU message.
ICU chooses the right branch (=0, one, other) based on:
the current locale (from IntlProvider), and
the numeric value of count.
If you now change locale in main.tsx from 'en' to 'fr' or 'ar', you’ll see completely different sentences but no code changes in your React components. That’s the main win: React renders the same, translators control the text.
Step 3: Why you should not hand-roll plural logic in components
The naive approach in React usually looks like this:
<p> You have {count} new message{count === 1 ? '' : 's'}</p>
This has a few problems:
It only works for English-like plural rules.
It mixes UI logic with language rules, which makes translators’ lives harder.
Once you add more locales, you’ll end up with a mess of if/else branches.
With react-intl + ICU:
You always pass the raw number (count).
The message itself declares plural logic.
The same React code works for every locale your app supports.
How to localize dates and times with react-intl
Different locales format dates and times in completely different ways: day/month/year vs month/day/year, 12h vs 24h, different separators, different month names, and sometimes even different numerals. React-intl solves all of this by building on top of the native Intl API. You pass a raw Date, and the library decides how it should look for the current locale.
The rule of thumb: store raw values in your code, and let react-intl format them.
Step 1: Add a date-time message to your translation files
We’ll add a new message with the ID app.lastLogin. All three locales use the same structure: a timestamp called ts, formatted as a date and a time.
src/i18n/messages/en.json
{ "app.lastLogin": "Last login: {ts, date, medium} at {ts, time, short}"}
src/i18n/messages/fr.json
{ "app.lastLogin": "Dernière connexion : {ts, date, medium} à {ts, time, short}"}
src/i18n/messages/ar.json
{ "app.lastLogin": "آخر تسجيل دخول: {ts, date, medium} في {ts, time, short}"}
Explanation:
{ts, date, medium} → uses the locale’s default medium date style
{ts, time, short} → uses the locale’s default short time style
You don’t hard-code formats; ICU handles it per locale.
Step 2: Pass a Date value from React into your message
Update your App.tsx so it includes a section showing the user’s last login (I've skipped irrelevant lines of code):
Use this only when the structure must remain the same across all languages. For real text that translators may need to rearrange → ICU is better.
Modern best practice: When a sentence contains human-readable text, put the formatting inside the message (ICU). When the UI structure is fixed, use formatting components.
Step 4: A note about time zones
By default, react-intl formats dates using the user’s local time zone. If you need to force a specific time zone (e.g., always UTC), do it at the provider:
This is optional; for most apps, the default is exactly what users expect.
How to localize prices and currencies with react-intl
Formatting money is where “just throw a $ in front of it” dies instantly. Different locales use different currency symbols, spacing, decimal separators, and even position of the currency (before/after the number). React-intl sits on top of the Intl API and knows how to format all of that per locale and currency, so you don’t have to.
Golden rule: Store prices as raw numbers in your code, let react-intl format them. No manual string building.
Step 1: Add a currency-aware message to your translation files
We’ll add a new message app.cartTotal that shows the total price in the user’s cart. For this tutorial, we’ll assume:
English locale uses USD
French locale uses EUR
Arabic locale uses AED (just as an example)
We’ll use ICU number skeletons so that formatting rules are fully controlled by the message.
How to localize gender-specific text with react-intl
Some languages change whole sentence structure depending on the user’s gender. Even in English, you can’t always avoid he/she/they.
The key rule here: Don’t do gender logic in React components. Let ICU decide the sentence.
React-intl uses the select keyword in ICU MessageFormat for this.
Step 1: Add a gender-select message to your translation files
We’ll use message ID app.userAction. It will say something like: “Ann logged in, she updated her profile.”
src/i18n/messages/en.json
{ "app.userAction": "{gender, select, female {{name} logged in, she updated her profile.} male {{name} logged in, he updated his profile.} other {{name} logged in, they updated their profile.}}"}
What’s happening here:
gender is the variable we pass from React.
select picks a branch based on the value.
female, male, other are case labels.
{name} gets injected normally (this is a placeholder and we basically use interpolation)
The other branch covers:
nonbinary users
unknown gender
“prefer not to say”
anything unmatched, as ICU falls back cleanly.
src/i18n/messages/fr.json
{ "app.userAction": "{gender, select, female {{name} s'est connectée et a mis à jour son profil.} male {{name} s'est connecté et a mis à jour son profil.} other {{name} s'est connecté·e et a mis à jour son profil.}}"}
src/i18n/messages/ar.json
{ "app.userAction": "{gender, select, female {{name} قامت بتسجيل الدخول وقامت بتحديث ملفها.} male {{name} قام بتسجيل الدخول وقام بتحديث ملفه.} other {{name} قام/قامت بتسجيل الدخول وقام/قامت بتحديث ملفه/ملفها.}}"}
(Arabic gender rules can be way more nuanced, so if you know the language well, feel free to tweak further.)
Step 2: Pass name + gender from React
Now let's update our App.tsx once again:
// ... imports ...function App() { // ... other variables ... const [user] = useState({ name: "Ann", gender: "female", // change to 'male' or 'other' and watch the text change });// and then inside <main>...<section style={{ marginTop: '2rem' }}> <p> <FormattedMessage id="app.userAction" values={{ name: user.name, gender: user.gender }} /> </p></section>
Step 3: Why this is the right way
No if/else inside components. Don't write stuff like:
{ user.gender === 'female' ? 'she' : 'he' }
That breaks instantly in other languages. Gender affects:
verbs
adjectives
word order
sometimes the whole sentence
Translators must control the full sentence. ICU handles it correctly for each locale.
How to build a language switcher for react-intl in a Vite + React app
Here's our goal for this part:
detect supported locales automatically from your messages/*.json files
keep list of languages in one place
render a small language switcher component in its own file
change locale at runtime and re-render all translated content
Vite makes the “scan a folder at build time” part easy with import.meta.glob.
Step 1: Auto-discover locales from your messages folder
Create a file src/i18n/locales.ts and let Vite load all *.json files under src/i18n/messages:
messagesMap[LOCALE] → contents of the corresponding JSON file
supportedLocales → ['ar', 'en', 'fr'] (sorted)
defaultLocale → 'en' if present, otherwise the first one
The cool part: If you later drop de.json into src/i18n/messages, it’s automatically picked up without extra code changes.
Step 2: Create a reusable LanguageSwitcher component
Now we build a small and pretty dumb component that:
knows which locales are available
shows them as buttons or a select
calls onChange(locale) when user picks something
Create src/i18n/LanguageSwitcher.tsx:
// src/i18n/LanguageSwitcher.tsximport type React from "react";import { supportedLocales } from "./locales";const labels: Record<string, string> = { en: "English", fr: "Français", ar: "العربية", // fallback: show locale code if not listed here};type LanguageSwitcherProps = { locale: string; onChange: (locale: string) => void;};export const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ locale, onChange,}) => { if (supportedLocales.length <= 1) { // No point showing a switcher if there is only one locale return null; } return ( <div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem" }}> {supportedLocales.map((code) => ( <button key={code} type="button" onClick={() => onChange(code)} disabled={code === locale} style={{ padding: "0.25rem 0.75rem", borderRadius: 4, border: "1px solid #ccc", opacity: code === locale ? 0.7 : 1, cursor: code === locale ? "default" : "pointer", }} > {labels[code] ?? code} </button> ))} </div> );};
You can later swap buttons for a select or fancy UI, but the behavior stays the same: it’s a pure presentational component with locale + onChange.
Step 3: Move IntlProvider and locale state into a small root component
Up to this point, we've been hard-coding the locale inside main.tsx. That works for a static demo, but a language switcher needs actual React state. Since main.tsx can't use hooks, we create a tiny Root component that wraps the entire app with IntlProvider and keeps the current locale in state.
locale is now a piece of state instead of a hard-coded value.
setLocale() triggers a re-render, and IntlProvider updates every translated string automatically.
messagesMap gives us the right translation bundle based on the selected locale.
The switcher lives inside the provider, so it updates live.
Step 4: Update main.tsx to render Root instead of wiring IntlProvider manually
Finally, simplify src/main.tsx:
// src/main.tsximport { StrictMode } from "react";import { createRoot } from "react-dom/client";import "./index.css";import { Root } from "./Root";createRoot(document.getElementById("root") as HTMLElement).render( <StrictMode> <Root /> </StrictMode>,);
main.tsx is now boring again (which is good). All i18n logic lives in:
src/i18n/messages/*.json — actual translations
src/i18n/locales.ts — discovery and wiring of messages
src/i18n/LanguageSwitcher.tsx — UI component
src/Root.tsx — locale state + IntlProvider
From a DX point of view this feels nice:
To add a new language, you drop xx.json into messages.
It automatically appears in supportedLocales.
If you also add a label to labels in LanguageSwitcher, it gets a human-friendly name.
No manual editing of arrays or giant config objects scattered across the app.
How to handle RTL languages (like Arabic) with react-intl
React-intl will translate your text and format numbers/dates, but it will not automatically flip your layout or fix the html attributes for you. If you add Arabic, Hebrew, or any other RTL language, you have to handle two things yourself:
The document language (html lang="...")
The text direction (dir="ltr" vs dir="rtl")
If you skip this, your Arabic text will still show up, but:
the page direction will stay LTR
browser and screen readers won’t know the real language
some punctuation and mixed content (numbers + text) will look awkward
Step 1: Map each locale to a text direction
First, decide which locales are RTL. For our demo (en, fr, ar) it’s simple: only ar is RTL.
In src/i18n/locales.ts (where we already discover locales), we can export a small helper:
export function getDirection(locale: string): 'ltr' | 'rtl' { const normalized = locale.toLowerCase() if (normalized.startsWith('ar')) return 'rtl' return 'ltr'}
You can extend this later with he, fa, ur, etc.
Step 2: Update html lang and dir when locale changes
Now we hook into the selected locale in Root and sync it with the real document. This keeps SEO, accessibility, and layout direction honest.
Update src/Root.tsx:
// src/Root.tsximport { useEffect, useState } from 'react'import { IntlProvider } from 'react-intl'import App from './App'import { LanguageSwitcher } from './i18n/LanguageSwitcher'import { defaultLocale, messagesMap, getDirection } from './i18n/locales'export function Root() { const [locale, setLocale] = useState<string>(defaultLocale) const messages = messagesMap[locale] const dir = getDirection(locale) useEffect(() => { const html = document.documentElement html.lang = locale html.dir = dir }, [locale, dir]) return ( // ... return here ... );}
What this does:
When the user switches from en to ar, html lang="en" dir="ltr" becomes html lang="ar" dir="rtl".
Browsers, screen readers, and CSS now know the actual language and direction.
If your layout or components rely on dir, they react to it correctly.
Step 3: Keep your layout RTL-friendly
Our demo layout is very neutral: centered column, no explicit left/right alignment, so it survives RTL pretty well. In real apps, though, you'll often have things like:
margin-left / margin-right
left-aligned nav, right-aligned actions
icons that assume an LTR flow
For RTL-friendly CSS, aim for:
logical properties: margin-inline-start, padding-inline-end, text-align: start / end
avoid hard-coded left/right unless you really mean physical left/right
test your app at least once with an RTL locale turned on and dir="rtl" to catch weird layout issues
How to detect the user's preferred locale (and fall back safely)
Most users never touch a language switcher. They rely on the browser to tell websites which languages they prefer. React-intl doesn't auto-detect this for you, but grabbing that preference is easy, as long as you add proper fallback logic.
Our goal is:
Try to detect the user's preferred locale.
Check if we support it.
If not, fall back to our default (e.g., "en").
Use this locale when initializing the app.
Step 1: Read the browser's language preferences
Modern browsers expose the user's preferred languages as an ordered list.
navigator.languages // e.g. ["fr-CA", "fr", "en-US", "en"]navigator.language // e.g. "fr-CA"
navigator.languages is the better one because it gives multiple fallback options.
Step 2: Normalize the locale so it matches our file names
Your translation files are named like: en.json, fr.json, and so on. But browser locales may come as: en-US, fr-CA, etc.
Step 3: Pick the first supported locale from the browser’s list
Create a helper in src/i18n/locales.ts:
// src/i18n/locales.tsexport function detectUserLocale(): string { const browserLocales = navigator.languages && navigator.languages.length > 0 ? navigator.languages : [navigator.language] for (const raw of browserLocales) { const base = normalizeLocale(raw) if (supportedLocales.includes(base)) { return base } } return defaultLocale}function normalizeLocale(locale: string): string { return locale.toLowerCase().split('-')[0]}
What this does:
Reads browser's preferred languages in order.
Normalizes them (fr-CA → fr).
Picks the first one we actually support.
Falls back to defaultLocale if none match.
This means:
French-Canadian users get French.
Arabic-Egypt users get Arabic.
Users with unsupported locales still get a sane default.
Step 4: Use detected locale when app starts
Update Root.tsx:
// src/Root.tsximport { useEffect, useState } from "react";import { IntlProvider } from "react-intl";import App from "./App";import { LanguageSwitcher } from "./i18n/LanguageSwitcher";import { messagesMap, getDirection, detectUserLocale } from "./i18n/locales";export function Root() { const initial = detectUserLocale(); const [locale, setLocale] = useState(initial); const messages = messagesMap[locale]; const dir = getDirection(locale); useEffect(() => { const html = document.documentElement; html.lang = locale; html.dir = dir; }, [locale, dir]); return ( // ... your return ... );}
Some best practices on language detection
Use navigator.languages instead of navigator.language: you get the user’s full preference list.
Normalize locale codes (pt-BR → pt, en-GB → en) so they match your file structure.
Don't rely on geolocation! Users travel, use VPNs, or live abroad; location ≠ language.
Always fallback to a safe default locale if you can’t match anything.
Never break the UI on unknown locales (yes, some people actually have tlh-KLINGON).
Let the user’s manual choice override auto-detection every time.
Detect locale once at startup and keep it stable; don’t re-detect during rendering.
Make switching easy because some users intentionally jump between languages.
How to persist the selected locale (localStorage)
Auto-detecting the user's locale is nice for the first visit, but after that you should respect the user's choice. If they manually switch from English to French, the app should remember it, even after a page refresh.
Our goal:
Try to load the saved locale from localStorage.
If not found, detect browser locale.
If unsupported, use default.
When the user switches locale, save it back to localStorage.
Initial state uses getInitialLocale() but messages is null until we finish loading.
When locale changes, we call loadMessages(locale), update messages, and store the locale in localStorage.
while we’re waiting, we show a small “Loading translations…” message.
If loading fails, we show an error instead of silently crashing.
We still update html lang and dir on every locale change.
Step 3: The language switcher stays "dumb"
Good news: LanguageSwitcher doesn’t care if messages are lazy or eager. It only needs:
the current locale,
an onChange(locale) callback.
So you can keep it exactly as it was.
Why lazy-loading locale bundles is worth it
Short version:
Your initial bundle only contains one locale (the one user actually sees).
New locales are pulled on demand as separate chunks.
You don’t have to maintain a giant config array manually; dropping a new xx.json file is usually enough.
This pattern scales much better when your app grows beyond “two languages and a few strings.”
Using the useIntl hook: when and why it matters
So far we've used FormattedMessage and friends to render translated text directly inside JSX. That works great for simple UI, but real apps often need translated values inside logic, not just as JSX elements. That’s where the useIntl hook comes in.
When to utilize useIntl
Take advantage of useIntl when you need translations as values, for example:
building a dynamic label before passing it into a 3rd-party component
formatting dates, numbers, or currencies inside business logic
preparing text for toast notifications
generating aria- labels or metadata
logging or analytics events that depend on locale
Basically:
components → use Formatted
logic → use useIntl()
The core API: formatMessage, formatNumber, formatDate, formatTime
The hook gives you an intl object:
import { useIntl } from 'react-intl'const intl = useIntl()
You get the same locale-aware results as <FormattedNumber /> et al., but now it's all available in your logic layer.
When not to use useIntl
If you're just rendering static text inside JSX, FormattedMessage is simpler:
<FormattedMessage id="app.header.title" />
Stick to useIntl only when you need values, not components.
Handling rich text (links, bold, inline components) in react-intl
Sometimes your translations aren’t just plain text. You might need to style part of a sentence, insert a link, wrap something in strong, or even drop in a React component without hardcoding markup inside your messages. React-intl solves this with pseudo-tags and value functions.
The idea: Your translation contains tags like <bold> or <link>. Not real HTML, just markers. Then you tell react-intl how to render them.
Example message:
{ "app.learnMore": "Click <link>here</link> to learn more."}
Teams can own specific namespaces without stepping on each other.
Translation sync becomes more predictable.
You can lazy-load only the namespaces you actually need.
Then you slightly change your loader. Instead of scanning messages/*.json, you scan messages/LOCALE/*.json. Vite makes that easy:
// src/i18n/locales.ts// A single locale bundle in our app is just a flat key -> string map// (react-intl expects messages in this shape).type Messages = Record<string, string>// Vite will turn this into a map of lazy import functions.// Keys look like: "./messages/en/common.json", "./messages/fr/errors.json", etc.const messageModules = import.meta.glob('./messages/*/*.json') as Record< string, () => Promise<{ default: Messages }>>// We store locales in directory names, e.g. "./messages/en/common.json" -> "en"const localeDirRegex = /\.\/messages\/([a-z0-9-]+)\//i// Build the list of supported locales by scanning all matched file paths,// extracting the locale directory name, then deduping via Set.export const supportedLocales: string[] = Array.from( new Set( Object.keys(messageModules) .map((path) => path.match(localeDirRegex)?.[1] ?? null) .filter((v): v is string => Boolean(v)) )).sort()// Load ALL namespace JSON files for a given locale (e.g. en/common.json + en/errors.json)// and merge them into a single messages object for react-intl.export async function loadMessages(locale: string): Promise<Messages> { // Only match files inside the selected locale folder. const prefix = `./messages/${locale}/` // Get all loaders for that locale. const loaders = Object.entries(messageModules).filter(([path]) => path.startsWith(prefix) ) // If there are no files for this locale, we can't load it. if (loaders.length === 0) { throw new Error(`No messages found for locale "${locale}"`) } // Dynamically import every namespace file for this locale. const modules = await Promise.all(loaders.map(([, loader]) => loader())) // Merge all namespaces into one flat object. // Note: if two namespaces reuse the same key, the later one will overwrite the earlier one. return Object.assign({}, ...modules.map((m) => m.default))}
When you use messages/LOCALE/NAMESPACE.json, the locale comes from the folder name. At runtime we load every JSON file inside that locale folder (like messages/fr/*), merge them into one flat object, and pass that to IntlProvider. So you keep translations split into sane chunks, but react-intl still gets the single messages map it expects.
Consider using a Translation Management System (TMS)
If your team is bigger than one developer, or you support more than 3–4 languages, hand-editing JSON isn’t just annoying, it becomes a source of bugs and missed translations.
Use glossaries, style guides, and translation memory (TMS power tools)
When your app starts growing, consistency becomes a real problem. Teams often end up with:
“Sign in” in one language file
“Log in” in another
A mix of formal/informal tone
Different translations for the same domain-specific term
Translators reinventing the same phrases 20 times
This is where proper TMS tooling makes a huge difference. A system like Lokalise gives you:
Glossaries
Define canonical translations for important words (“user”, “profile”, “billing”, “admin dashboard”, etc.). Translators never guess; they follow the glossary.
Style guides
Set a consistent tone of voice, formality level, punctuation rules, capitalization rules, emoji policy, and UI wording conventions.
This ensures that all translations sound like one product, not fifty people freelancing blindfolded.
Translation memory (TM)
Any sentence translated once becomes a stored memory. Next time a similar string appears, the TMS suggests the existing translation. This:
speeds up translation
improves consistency
cuts costs if you hire pro translators
reduces human error
AI with context-awareness
Modern TMS platforms (including Lokalise) let AI:
read your glossary
follow your style guide
respect placeholders/ICU syntax
use translation memory
avoid hallucinating new keys
generate consistent suggestions instead of generic Google Translate junk
This is exponentially better than “AI but raw.”
Why this is useful
When your app supports 10+ locales, consistency is more important than speed. Glossaries, style guides, TM, and context-aware AI:
make translations predictable
protect brand voice
keep quality high
prevent regressions when teams change
save tons of time as your product grows
Integrating Figma design with localization (Lokalise)
When you localize a real product, translating code strings isn’t enough. The real trouble starts earlier: at the design stage. Text expands, contracts, moves differently in RTL, and sometimes just breaks the layout. Connecting your design tool (Figma) with a TMS like Lokalise makes the whole workflow smoother and far less painful.
Why connect Figma to your localization pipeline
If you design in English only and translate at the end, you get the classic mess:
buttons suddenly don’t fit
labels wrap in the wrong places
RTL text looks misaligned
translators have no idea what a string refers to
When you sync Figma with your TMS, you fix all of that upfront. You can:
export text layers directly from Figma into your localization project
auto-generate translation keys devs will actually use
preview translated text directly inside Figma
switch the entire design into French, Arabic, or any supported locale
catch layout issues early instead of during development
Designers, developers, and translators finally work on the same page instead of guessing what each other meant.
How the workflow typically looks
Install the Lokalise plugin in Figma. Designers run it directly from the editor.
Connect the plugin to your Lokalise project. You can link an existing project or create a new one right from the plugin.
Export UI copy from your frames. The plugin turns Figma text layers into organized translation keys. No copy-paste, no typos, no manual syncing.
Translators work with full context. They see real screen previews and know exactly where each string appears, which leads to better translations and fewer revisions.
Import translations back into Figma. Designers instantly see how French, Arabic, or any other language affects the layout. Overflow and RTL issues become visible long before development starts.
Why context matters so much
Strings without context force translators to guess. Guessing leads to:
incorrect tone
inconsistent terminology
mismatched UI wording
phrases that don’t fit the layout at all
How this ties into your React workflow
Once your Figma ↔ Lokalise pipeline is running:
designers ensure layouts work in all languages
translators produce clean, context-aware content
developers receive structured JSON/ICU files
these files drop directly into your src/i18n/messages/LOCALE folders
react-intl handles rendering, formatting, plurals, dates, currencies, and RTL
No guessing, no late surprises, no scrambling to fix broken UI at the last minute.
React-intl makes React i18n predictable (and scalable)
At this point you’ve got a modern setup that actually holds up in real apps: clean message structure, proper plurals and formatting, RTL support, locale detection with safe fallbacks, a language switcher, persistence, and lazy-loaded translation bundles. From here, the main work is just adding more messages and keeping your translation workflow consistent as the project grows.
Thank you for staying with me, and until next time!
FAQ: React i18n basics
Which React i18n library should you choose in 2026?
If you want a stable, standards-based solution with strong ICU formatting, pick react-intl. If you need a plugin-heavy ecosystem (language detection, backend loaders, tons of integrations), react-i18next is often a better fit.
React-intl vs react-i18next: what's the difference?
react-intl focuses on ICU message formatting and the Intl API. react-i18next is more ecosystem-driven and flexible, especially for async translation loading and plugin-based features.
Is react-intl still worth using in modern React apps?
Yes. It’s mature, actively maintained, works well with React + Vite, and handles plurals, dates, numbers, and currency reliably via Intl.
What is the simplest i18n library for React?
Lingui is often the simplest to start with if you want low runtime overhead and a dev-friendly workflow.
Do I need a backend to load translations?
No. You can bundle JSON files with your app, or lazy-load them on demand to keep the initial bundle smaller.
Should I use ICU MessageFormat for plurals and complex messages?
Yes. ICU is the safest way to handle plurals, gender, and locale-specific rules without writing language logic in your components.
How do I build a language switcher with react-intl?
Keep the current locale in React state, swap the messages passed to IntlProvider, and persist the choice (localStorage is the common default).
How do I detect a user’s locale correctly?
Use navigator.languages, normalize locale codes (fr-CA → fr), and always fall back to a default if the locale isn’t supported.
Does react-intl support RTL languages like Arabic?
It supports RTL text formatting, but you must set dir="rtl" and update html lang="ar" yourself when switching locales.
How do I lazy-load translation files in React?
Use Vite dynamic imports (import.meta.glob) to load only the initial locale on startup, then fetch other locales when the user switches.
Ilya is the lead for content, documentation, and onboarding at Lokalise, where he focuses on helping engineering teams build reliable internationalization workflows. With a background at Microsoft and Cisco, he combines practical development experience with a deep understanding of global product delivery, localization systems, and developer education.
He specializes in i18n architectures across modern frameworks — including Vue, Angular, Rails, and custom localization pipelines — and has hands-on experience with Ruby, JavaScript, Python, Elixir, Go, Rust, and Solidity. His work often centers on improving translation workflows, automation, and cross-team collaboration between engineering, product, and localization teams.
Beyond his role at Lokalise, Ilya is an IT educator and author who publishes technical guides, best-practice breakdowns, and hands-on tutorials. He regularly contributes to open-source projects and maintains a long-standing passion for teaching, making complex internationalization topics accessible to developers of all backgrounds.
Outside of work, he keeps learning new technologies, writes educational content, stays active through sports, and plays music. His goal is simple: help developers ship globally-ready software without unnecessary complexity.
Ilya is the lead for content, documentation, and onboarding at Lokalise, where he focuses on helping engineering teams build reliable internationalization workflows. With a background at Microsoft and Cisco, he combines practical development experience with a deep understanding of global product delivery, localization systems, and developer education.
He specializes in i18n architectures across modern frameworks — including Vue, Angular, Rails, and custom localization pipelines — and has hands-on experience with Ruby, JavaScript, Python, Elixir, Go, Rust, and Solidity. His work often centers on improving translation workflows, automation, and cross-team collaboration between engineering, product, and localization teams.
Beyond his role at Lokalise, Ilya is an IT educator and author who publishes technical guides, best-practice breakdowns, and hands-on tutorials. He regularly contributes to open-source projects and maintains a long-standing passion for teaching, making complex internationalization topics accessible to developers of all backgrounds.
Outside of work, he keeps learning new technologies, writes educational content, stays active through sports, and plays music. His goal is simple: help developers ship globally-ready software without unnecessary complexity.
Libraries and frameworks to translate JavaScript apps
In our previous discussions, we explored localization strategies for backend frameworks like Rails and Phoenix. Today, we shift our focus to the front-end and talk about JavaScript translation and localization. The landscape here is packed with options, which makes many developers a
Syncing Lokalise translations with GitLab pipelines
In this guide, we’ll walk through building a fully automated translation pipeline using GitLab CI/CD and Lokalise. From upload to download, with tagging, version control, and merge requests. Here’s the high-level flow: Upload your source language files (e.g. English JSON files) to Lokalise from GitLab using a CI pipeline.Tag each uploaded key with your Git branch name. This helps keep translations isolated per feature or pull request
Build a smooth translation pipeline with Lokalise and Vercel
Internationalization can sometimes feel like a massive headache. Juggling multiple JSON files, keeping translations in sync, and redeploying every time you tweak a string… What if you could offload most of that grunt work to a modern toolchain and let your CI/CD do the heavy lifting? In this guide, we’ll wire up a Next.js 15 project hosted on Vercel. It will load translation files on demand f