Developer Guides & Tutorials

React i18n: A step-by-step guide to React-intl

Ilya Krukowski,Updated on October 3, 2024·37 min read
Lokalise graphic for React-intl guide

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.

The source code for this article is available on GitHub.

You may also be interested in learning how to translate React apps with react-I18next.

What is localization?

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-lokalise
pnpm 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-lokalise
npm 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.

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:

  • ICU plural rules
  • gendered phrases
  • nested messages
  • date/number/currency formatting
  • RTL text handling
  • locale-specific quirks (e.g., Slavic plurals, Arabic shaping)

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: Flexible, plugin-driven, highly dynamic

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):

src/
  i18n/
    messages/
      en.json
      fr.json
      ar.json
  App.tsx
  main.tsx

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.tsx
import { 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:

// src/App.tsx
import { FormattedMessage } from 'react-intl'

function App() {
  return (
    <main style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui, sans-serif' }}>
      <h1>
        <FormattedMessage id="app.title" />
      </h1>
      <p>
        <FormattedMessage id="app.description" />
      </p>
    </main>
  )
}

export default App

What is happening here:

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

Replace src/App.tsx with:

// src/App.tsx
import { useState } from 'react'
import { FormattedMessage } from 'react-intl'

function App() {
  const [count, setCount] = useState(0)

  return (
    <main style={{ maxWidth: 600, margin: '2rem auto', fontFamily: 'system-ui, sans-serif' }}>
      {/* Simple heading */}
      <h1>
        <FormattedMessage id="app.title" />
      </h1>

      {/* Simple paragraph */}
      <p>
        <FormattedMessage id="app.description" />
      </p>

      {/* Pluralized notifications */}
      <section style={{ marginTop: '2rem' }}>

        <p>
          <FormattedMessage
            id="app.notifications"
            values={{ count }}
          />
        </p>

        <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
          <button type="button" onClick={() => setCount(0)}>
            =0
          </button>
          <button type="button" onClick={() => setCount(1)}>
            =1
          </button>
          <button type="button" onClick={() => setCount((prev) => prev + 1)}>
            +1
          </button>
        </div>
      </section>
    </main>
  )
}

export default App;

Key parts:

  • FormattedMessage receives:
    • 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):

// ... imports ...

function App() {
  const [lastLogin] = useState(() => new Date());

  return (
    <main
      style={{
        maxWidth: 600,
        margin: "2rem auto",
        fontFamily: "system-ui, sans-serif",
      }}
    >

      <section style={{ marginTop: "2rem" }}>
        <p>
          <FormattedMessage id="app.lastLogin" values={{ ts: lastLogin }} />
        </p>
      </section>

    </main>
  );
}

export default App;

Here’s what matters:

  • You pass a raw Date object to ICU: values={{ ts: lastLogin }}
  • ICU applies the right formats based on the current locale
  • Changing locale in main.tsx automatically updates the output without touching any React code

This separation (data in React, formatting in ICU) is the whole point.

Step 3: ICU vs FormattedDate / FormattedTime

React-intl gives you two approaches to formatting dates:

{
  "app.lastLogin": "Last login: {ts, date, medium} at {ts, time, short}"
}

Advantages:

  • Translators control the entire sentence
  • Word order, punctuation, and prepositions can change per locale
  • Works best for actual content (“Last login…”, “Created on…”, etc.)

2) JSX components (useful for fixed UI layouts)

<p>
  Last login:
  <FormattedDate value={lastLogin} dateStyle="medium" />{' '}
  <FormattedTime value={lastLogin} timeStyle="short" />
</p>

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:

<IntlProvider locale={locale} messages={messages} timeZone="UTC">
  <App />
</IntlProvider>

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.

src/i18n/messages/en.json

Extend your existing file with:

{
  "app.cartTotal": "Cart total: {total, number, ::currency/USD}"
}

src/i18n/messages/fr.json

{
  "app.cartTotal": "Total du panier : {total, number, ::currency/EUR}"
}

src/i18n/messages/ar.json

{
  "app.cartTotal": "إجمالي السلة: {total, number, ::currency/AED}"
}

What ::currency/USD does here:

  • number → tells ICU we’re formatting a number (yeah, I'm playing captain Obvious here, I know)
  • ::currency/USD → formats the number as money in USD using the correct pattern for the active locale (grouping, decimals, symbol, spacing)

So 1234.5 might become:

  • en (US): "Cart total: $1,234.50"
  • fr (FR): "Total du panier : 1 234,50 €" (different grouping and decimal separators)
  • ar: same idea, but with RTL and localized digits depending on the environment

Step 2: Pass the raw amount from React

Now let’s render this in App.tsx. We’ll pretend the cart total is 1234.5 (like $1234.50).

Add a “Cart total” section to your app:

// src/App.tsx

function App() {
  const [cartTotal] = useState(1234.5);

  return (
    <main
      style={{
        maxWidth: 600,
        margin: "2rem auto",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      {/* Currency / cart total */}
      <section style={{ marginTop: "2rem" }}>
        <p>
          <FormattedMessage id="app.cartTotal" values={{ total: cartTotal }} />
        </p>
      </section>
    </main>
  );
}

export default App;

Key points:

  • You pass total: cartTotal as a number, not a string.
  • The message decides how to display it.
  • Switching locale in main.tsx changes formatting automatically (symbol, separators, spacing) without touching your component.

Step 3: What about cents and internal representation?

Real apps often store money in the smallest unit (cents) to avoid floating point issues, like: 123450 (cents) instead of 1234.5 (dollars).

Best practice in that case:

  • keep your data layer in smallest units
  • convert to major units right before formatting

Example:

const [cartTotalInCents] = useState(123450)
const cartTotal = cartTotalInCents / 100

<FormattedMessage id="app.cartTotal" values={{ total: cartTotal }} />

Don’t try to manually add symbols or separators. Let the formatter own it.

Step 4: When to use ICU vs FormattedNumber

Just like with dates, you have two ways to handle currency:

{
  "app.cartTotal": "Cart total: {total, number, ::currency/USD}"
}

Pros:

  • translators control the whole sentence
  • text, order, and spacing can be different per locale
  • perfect for labels, hints, and user-facing strings

2) FormattedNumber in JSX (for purely numeric UI)

import { FormattedNumber } from 'react-intl'

<p>
  <FormattedNumber value={cartTotal} style="currency" currency="USD" />
</p>

This is useful when:

  • the UI is very strict, like a table column with just prices
  • there’s no surrounding sentence to translate
  • you want to format many numbers in the same way in one place

Rule of thumb: If there’s real text around the price, put everything in the message. If you’re just showing a naked number, FormattedNumber is fine.

Quick notes for working with currency

React-intl doesn’t convert currencies

It only formats numbers. So, if you need USD → EUR → AED → etc., you must convert the value yourself before formatting.

Don’t store money as floats

Floats cause rounding issues. Store amounts in the smallest unit (like cents), then divide before formatting:

const displayValue = cents / 100

Keep formatting in ICU, not in components

Use messages like:

{
  "app.cartTotal": "Cart total: {total, number, ::currency/USD}"
}

so translators control the whole sentence.

Get a demo of Lokalise

Get a demo

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:

// src/i18n/locales.ts
const modules = import.meta.glob('./messages/*.json', {
  eager: true
}) as Record<string, { default: Record<string, string> }>

export const messagesMap: Record<string, Record<string, string>> = {}

for (const path in modules) {
  const match = path.match(/\.\/messages\/([a-z0-9-]+)\.json$/i)
  if (!match) continue
  const locale = match[1]
  messagesMap[locale] = modules[path].default
}

export const supportedLocales = Object.keys(messagesMap).sort()

export const defaultLocale =
  supportedLocales.includes('en') ? 'en' : supportedLocales[0]

What this gives you:

  • 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.tsx
import 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.

Create src/Root.tsx:

// src/Root.tsx
import { useState } from "react";
import { IntlProvider } from "react-intl";
import App from "./App";
import { LanguageSwitcher } from "./i18n/LanguageSwitcher";
import { defaultLocale, messagesMap } from "./i18n/locales";

export function Root() {
  const [locale, setLocale] = useState<string>(defaultLocale);
  const messages = messagesMap[locale];

  return (
    <IntlProvider locale={locale} messages={messages}>
      <div
        style={{
          maxWidth: 600,
          margin: "2rem auto",
          fontFamily: "system-ui, sans-serif",
        }}
      >
        <LanguageSwitcher locale={locale} onChange={setLocale} />
        <App />
      </div>
    </IntlProvider>
  );
}

What this does:

  • 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.tsx
import { 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.tsx
import { 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.

So we normalize them to their base language:

// src/i18n/locales.ts
function normalizeLocale(locale: string): string {
  return locale.toLowerCase().split('-')[0]
}

Step 3: Pick the first supported locale from the browser’s list

Create a helper in src/i18n/locales.ts:

// src/i18n/locales.ts
export 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-CAfr).
  • 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.tsx
import { 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.

Step 1: Add helpers to load/save the locale

In src/i18n/locales.ts, add:

const STORAGE_KEY = 'app-locale'

export function loadStoredLocale(): string | null {
  try {
    return localStorage.getItem(STORAGE_KEY)
  } catch {
    return null
  }
}

export function storeLocale(locale: string): void {
  try {
    localStorage.setItem(STORAGE_KEY, locale)
  } catch {
    // ignore storage errors (private mode, disabled cookies, etc.)
  }
}

This safely interacts with localStorage without blowing up if storage is blocked.

Step 2: Merge autodetection + storage logic

We combine three sources:

  • User's saved locale
  • User's browser preference
  • Default locale (final fallback)

Let's add this function:

export function getInitialLocale(): string {
  // 1. User preference (if valid)
  const saved = loadStoredLocale()
  if (saved && supportedLocales.includes(saved)) {
    return saved
  }

  // 2. Browser preference
  const detected = detectUserLocale()
  if (supportedLocales.includes(detected)) {
    return detected
  }

  // 3. Hard fallback
  return defaultLocale
}

Step 3: Integrate persistence into Root.tsx

In Root, replace your old locale initialization:

// src/Root.tsx

// Make sure to update imports:
import { messagesMap, getDirection, getInitialLocale, storeLocale } from "./i18n/locales";

// ...
// And inside the function:

const initial = getInitialLocale()
const [locale, setLocale] = useState(initial)

And anytime locale changes, store it:

useEffect(() => {
  storeLocale(locale);
}, [locale]);

Now the app:

  • Detects locale on first load
  • Remembers the user’s choice
  • Restores it automatically after refresh
  • Still falls back to default if unsupported
  • Updates html lang and dir properly

All the good stuff.

How to lazy-load locale bundles with react-intl and Vite

If your app supports more than a couple of languages, loading all translation files upfront is a waste. In a Vite + React app you can:

  • load only one locale on initial page load (the one the user actually needs),
  • fetch other locales on demand when the user switches language.

We’ll use import.meta.glob with dynamic imports to do that. Update src/i18n/locales.ts:

// src/i18n/locales.ts
const STORAGE_KEY = 'app-locale'

const messageModules = import.meta.glob('./messages/*.json')

type Messages = Record<string, string>

export async function loadMessages(locale: string): Promise<Messages> {
  const key = `./messages/${locale}.json`
  const loader = messageModules[key]

  if (!loader) {
    throw new Error(`No messages found for locale "${locale}"`)
  }

  const mod = (await loader()) as { default: Messages }
  return mod.default
}

// Discover supported locales from file names
const localeFromPathRegex = /\.\/messages\/([a-z0-9-]+)\.json$/i

export const supportedLocales: string[] = Object.keys(messageModules)
  .map((path) => {
    const match = path.match(localeFromPathRegex)
    return match ? match[1] : null
  })
  .filter((v): v is string => Boolean(v))
  .sort()
  
export const defaultLocale =
  supportedLocales.includes('en') ? 'en' : supportedLocales[0] ?? 'en'
  
  
// ... getDirection, locale detection, and local storage-related functions stay in place ...

What changed compared to the eager version:

  • We no longer import en.json, fr.json, ar.json directly.
  • loadMessages(locale) dynamically imports only the requested locale file.
  • supportedLocales is still derived from file names, but now we use the keys from messageModules.

Step 2: Load the initial locale before rendering the app

Now we need to:

  • figure out which locale to use (getInitialLocale()),
  • load its messages lazily,
  • only then render IntlProvider and the rest of the app.

We’ll keep this logic in Root, so update src/Root.tsx:

// src/Root.tsx
import { useEffect, useState } from "react";
import { IntlProvider } from "react-intl";
import App from "./App";
import { LanguageSwitcher } from "./i18n/LanguageSwitcher";
import {
  getDirection,
  getInitialLocale,
  loadMessages,
  storeLocale,
} from "./i18n/locales";

type LocaleState = {
  locale: string;
  messages: Record<string, string> | null;
  loading: boolean;
  error: string | null;
};

export function Root() {
  const [state, setState] = useState<LocaleState>(() => ({
    locale: getInitialLocale(),
    messages: null,
    loading: true,
    error: null,
  }));

  const { locale, messages, loading, error } = state;
  const dir = getDirection(locale);

  // Load messages whenever locale changes
  useEffect(() => {
    let cancelled = false;

    setState((prev) => ({ ...prev, loading: true, error: null }));

    loadMessages(locale)
      .then((msgs) => {
        if (cancelled) return;
        setState((prev) => ({
          ...prev,
          messages: msgs,
          loading: false,
          error: null,
        }));
        storeLocale(locale);
      })
      .catch((err) => {
        if (cancelled) return;
        setState((prev) => ({
          ...prev,
          loading: false,
          error: err instanceof Error ? err.message : String(err),
        }));
      });

    return () => {
      cancelled = true;
    };
  }, [locale]);

  // Keep <html> in sync
  useEffect(() => {
    const html = document.documentElement;
    html.lang = locale;
    html.dir = dir;
  }, [locale, dir]);

  const handleLocaleChange = (nextLocale: string) => {
    if (nextLocale === locale) return;
    setState((prev) => ({
      ...prev,
      locale: nextLocale,
    }));
  };

  if (loading || !messages) {
    return (
      <div
        style={{
          maxWidth: 600,
          margin: "2rem auto",
          fontFamily: "system-ui, sans-serif",
        }}
      >
        <p>Loading translations…</p>
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          maxWidth: 600,
          margin: "2rem auto",
          fontFamily: "system-ui, sans-serif",
        }}
      >
        <p>Failed to load translations: {error}</p>
      </div>
    );
  }

  return (
    <IntlProvider locale={locale} messages={messages}>
      <div
        style={{
          maxWidth: 600,
          margin: "2rem auto",
          fontFamily: "system-ui, sans-serif",
        }}
      >
        <LanguageSwitcher locale={locale} onChange={handleLocaleChange} />
        <App />
      </div>
    </IntlProvider>
  );
}

What’s happening:

  • 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()

Now you can format anything:

Messages:

const label = intl.formatMessage({ id: 'app.buttons.save' })

Numbers

const amount = intl.formatNumber(total, {
  style: 'currency',
  currency: 'USD'
})

Dates and times

const lastSeen = intl.formatDate(date, { dateStyle: 'medium' })
const time = intl.formatTime(date, { timeStyle: 'short' })

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.

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."
}

Rendering it:

<FormattedMessage
  id="app.learnMore"
  values={{
    link: (chunks) => <a href="/docs">{chunks}</a>
  }}
/>

React-intl replaces <link> with whatever function you provide. chunks is the text between the tags.

Why this pattern is useful

  • No HTML inside translations (safer, cleaner, no XSS issues).
  • Translators control the sentence, devs control the structure.
  • Works with nested ICU: plurals, gender, everything.
  • Fits nicely into React’s component model.

Get started instantly with a free trial of Lokalise

Get a free trial

How to scale React-intl for large projects (many languages, many keys)

Everything is easy when you have en.json, fr.json, ar.json and 20 translation keys. But once you start dealing with:

  • 10+ locales
  • hundreds or thousands of messages
  • multiple teams working on the same app
  • …a single flat JSON file per language becomes unmanageable fast.

Here are the patterns real-world apps use these days.

Instead of this:

src/i18n/messages/en.json
src/i18n/messages/fr.json
src/i18n/messages/ar.json

Use this structure:

src/i18n/messages/
  en/
    common.json
    homepage.json
    dashboard.json
    errors.json
  fr/
    common.json
    homepage.json
    dashboard.json
    errors.json
  ar/
    common.json
    homepage.json
    dashboard.json
    errors.json

Advantages:

  • You avoid giant 3,000-line JSON files.
  • 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.

Modern teams use a TMS like Lokalise:

  • Centralized dashboard for all translations
  • Easy import/export with many supported formats
  • Automatic sync with GitHub/GitLab and similar services
  • Real professional translators just one click away
  • In-context editing (translators see the actual UI)
  • AI-assisted translations with contextual understanding
  • Glossaries, style guides, complex workflows
  • Quality checks (e.g., missing variables, tags, plural rules)
  • Rich API and webhooks support to build your own flows

TMS ≠ luxury. TMS = “I don’t want to break the whole app because someone forgot a comma in fr.json.”

Keep message keys stable and predictable

Good message keys are:

  • descriptive: dashboard.welcomeMessage
  • consistent: use the same naming scheme everywhere
  • stable: don’t rename keys every week (breaks translations)
  • not English phrases (avoid keys like "Hello world")

Bad keys generate translator pain and future tech debt.

Extract messages automatically (optional but powerful)

If your team uses inline ICU in code, consider automatic extraction tools:

  • Script parses your React components.
  • Finds all FormattedMessage id="...".
  • Validates missing keys.
  • Ensures consistency across languages.

This prevents “translation drift” when one locale has 250 keys and another has 237 because someone forgot to add something.

Lazy-load by namespace and locale

When your app grows, loading all languages and all namespaces is expensive. The scalable pattern:

  • Load only one locale on startup
  • Load only the namespaces the current route needs
  • Preload others in the background if helpful

Example:

  • /dashboard → load dashboard.json
  • /settings → load settings.json
  • /billing → load billing.json only when that route opens

This keeps your bundle small and initial load fast.

Document your content rules

Big teams need shared rules so translations don’t turn into chaos. A typical i18n guidelines doc includes:

  • key naming conventions
  • pluralization rules
  • gender rules
  • tone (formal/informal)
  • context notes for translators
  • instructions for using ICU
  • prohibited patterns (string concatenation, inline HTML, etc.)

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

  1. Install the Lokalise plugin in Figma. Designers run it directly from the editor.
  2. Connect the plugin to your Lokalise project. You can link an existing project or create a new one right from the plugin.
  3. Export UI copy from your frames. The plugin turns Figma text layers into organized translation keys. No copy-paste, no typos, no manual syncing.
  4. 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.
  5. 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-CAfr), 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.

Further reading

Developer Guides & Tutorials

Author

1517544791599.jpg

Lead of content, SDK/integrations dev

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 for translating JavaScript apps

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

Updated on April 28, 2025·Ilya Krukowski
gitlab_hero

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

Updated on August 18, 2025·Ilya Krukowski
vercel

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

Updated on August 13, 2025·Ilya Krukowski

Stop wasting time with manual localization tasks.

Launch global products days from now.

  • Lokalise_Arduino_logo_28732514bb (1).svg
  • mastercard_logo2.svg
  • 1273-Starbucks_logo.svg
  • 1277_Withings_logo_826d84320d (1).svg
  • Revolut_logo2.svg
  • hyuindai_logo2.svg