Most developers think setting up i18n early is unnecessary when launching with just one language. It feels like something to deal with later, once the product gains traction or expands to new markets.
However, when localization becomes necessary, the code is already full of hardcoded text. When you try to force it, UI elements break when translations don't fit, and pluralization rules start causing unexpected issues. Fixing it at that stage can be overwhelming and time-consuming.
This guide walks you step by step to getting i18n right from the start. You'll learn to choose the right tools, set up translations, handle tricky cases like pluralization and date formatting, and test multilingual readiness.
👀 Looking for a quick intro to React localization? Check out this article.
🔦 Choosing the right i18n tool 🔗
Before you start implementation, it's important to choose the best tool for the job. There are many i18n libraries, but not all of them are designed for the same use cases. Some are lightweight and flexible, while others provide more structured formatting options.
For this guide, we will use i18next, as it's one of the most widely used i18n frameworks. It supports multiple frontend and backend frameworks, including React, Vue, Angular, Node.js, and even mobile platforms like Flutter. It also handles pluralization, lazy loading, namespaces, and dynamic translation updates, making it a solid choice for scalable applications.
If you're working in React and want something more structured, react-intl (part of Format.js) offers built-in formatting components like <FormattedNumber>
and <FormattedDate>
. It also follows ICU message syntax, making pluralization and message interpolation more robust, but it's React-only.
When the time comes to manage the translations, Localazy integrates with these libraries to automate updates and allow teams to work with translators without modifying the codebase.
🧰 Setting up i18n in a React project 🔗
Now that you've chosen i18next to handle translations and Localazy to manage them, it's time to set up internationalization in your React app. This step will cover installing dependencies, setting up translation files, and integrating Localazy for translation management.
First steps 🔗
- If you don't already have a React project, use the commands below to create a new project with Vite:
npm create vite@latest my-i18n-app -- --template react
cd my-i18n-app
npm install
2. Once the installation is done, run npm run dev
to start the development server. This will launch your React app at http://localhost:5173/
.
3. Next, install the required dependencies for localization:
npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector
Each of these libraries plays a specific role:
i18next
: The core internationalization library that handles translations, pluralization, and formatting.react-i18next
: A React-specific integration that allows you to use translations inside components.i18next-http-backend
: Enables loading translation files dynamically from an external source, such as Localazy or a remote API.i18next-browser-languagedetector
: Detects the user's preferred language based on browser settings and previous selections.
4. After installing all the specified libraries, create a new file in the src
folder called i18n.js
and configure i18next
:
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
debug: true,
fallbackLng: 'en',
backend: {
loadPath: '/locales/{{lng}}.json',
},
});
export default i18n;
This initializes i18next with support for loading translations dynamically and detecting the user's language. The fallbackLng
option makes the app default to English if a translation is missing.
5. After setting up the i18n.js
configuration file, import it into main.jsx
so that the translation setup is loaded globally before your application renders. This step makes translations available to every component in your app without requiring manual imports in each file.
Open main.jsx
and import the i18n.js
file:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./i18n"; // Import the i18n configuration
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
By doing this, i18next is initialized before the React app renders, making translations available everywhere in your application.
Organizing translation files 🔗
For your React project, you will notice that the configuration file in i18n.js specifies the path from which i18next will load translations.
Vite serves static files from the public directory, so create the following structure:
/public/locales
├── en.json
For most projects, it's better to keep all translations in one file per language (locales/en.json
, locales/fr.json
). This keeps things easy to manage, avoids unnecessary fragmentation, and works well with CI/CD pipelines and translation management tools.
If your translation file starts getting too large, with hundreds or thousands of keys, consider splitting it by feature or UI section (e.g., auth.json
, dashboard.json
). However, avoid breaking it down into arbitrary categories like "words" or "phrases" that make things harder to find.
/locales
/en
common.json
auth.json
dashboard.json
/fr
common.json
auth.json
dashboard.json
Since you'll be using Localazy to manage translations, you only need to create the English (source) translations. You don't need to manually add multiple language files immediately, as Localazy will help you manage and pull other languages automatically when needed.
This is especially useful when setting up a project that doesn't require localization yet. Once additional languages are needed, you simply sync them with Localazy without changing the app's core functionality.
Beyond file structure, how you name your keys also plays a big role in keeping your translations manageable.
Naming your translation keys 🔗
How you structure translation keys makes a huge difference in readability and maintainability. A common mistake is using flat or unclear keys like this:
{
"news_title": "Latest News",
"article_title": "React 19 Is Coming!",
"article_content": "The next major version of React is set to release with improved performance, new hooks, and better server-side rendering.",
"learn_more": "To learn more, visit <1>React docs</1>."
}
At first glance, this structure might seem simple, but it becomes difficult to maintain as your app grows. There's no clear hierarchy, making it harder to find and organize translations. If a new feature related to news articles is added, it would be unclear how to fit the translations into this structure without creating long and repetitive key names (e.g., "news_page_article_title"
).
A better approach is to group related translations together in a hierarchical structure:
{
"news": {
"title": "Latest News",
"article": {
"title": "React 19 Is Coming!",
"content": "The next major version of React is set to release with improved performance, new hooks, and better server-side rendering."
},
},
"instructions": "To learn more, visit <1>React docs</1>."
}
Now, every translation key has context. Add the initial translations above in the en.json file.
Updating App.jsx to use translations 🔗
At this point, your application is set up to load translations dynamically. Modify App.jsx to display translations using useTranslation
from react-i18next
:
import { Suspense } from "react";
import "./App.css";
import { useTranslation, Trans } from "react-i18next";
function App() {
const { t, i18n } = useTranslation();
return (
<div className="App">
<h1>{t("news.title")}</h1>
<article>
<h2>{t("news.article.title")}</h2>
<p>{t("news.article.content")}</p>
</article>
<p>
<Trans i18nKey="instructions">
To learn more, visit <a href="https://reactjs.org">React docs</a>.
</Trans>
</p>
</div>
);
export default function WrappedApp() {
return (
<Suspense fallback="...loading translations">
<App />
</Suspense>
);
}
Here, t("key")
fetches translations from the JSON file. This is the standard way of retrieving localized text for headings, paragraphs, and other UI elements.
For cases where translations contain HTML elements, such as links inside sentences, you can use <Trans>
. This allows JSX elements like <a>
to be preserved inside translations without requiring developers to split and concatenate strings manually. This is particularly useful when working with non-developers or external translation teams, as it keeps the translation files clean and structured.
Since i18next loads translations asynchronously, it's essential to wrap the app in <Suspense>
to make sure it does not break while translations are being retrieved. If translations take a moment to load, the placeholder "...loading translations"
will be displayed instead of showing an empty screen or broken text.
Handling dynamic text 🔗
Before getting into how Localazy handles translations, let's see how to manage dynamic text properly in i18n.
A common mistake developers make is manually concatenating strings when inserting variables like usernames or numbers:
const { t } = useTranslation();
const username = "David";
return <p>{t("hello")} {username}!</p>;
If the translation file contains:
{
"hello": "Hello"
}
It works fine in English but breaks in languages with different sentence structures. For example, in Spanish, a greeting might be:
{
"hello": "¡Hola, {{name}}!"
}
So, instead of concatenating, use placeholders inside the translation file:
{
"news": {
"title": "Latest News",
"articles": {
"react19": {
"title": "React 19 Is Coming!",
"content": "The next major version of React is set to release with improved performance, new hooks, and better server-side rendering."
}
}
},
"greetings": {
"hello_user": "Hello, {{name}}! Welcome back to the latest news."
},
"instructions": "To learn more, visit <1>React docs</1>."
}
Then, in your component:
const { t } = useTranslation();
const username = "David";
return <p>{t("greetings.hello_user", { name: username })}</p>;
This keeps translations flexible and grammatically correct across all languages.
🥣 Integrating Localazy for translation management 🔗
Instead of manually creating and updating translation files, you can use Localazy to handle translations more efficiently. You'll just need to upload your source language files, translate them through the platform, and then pull the translated content into your project. This way, you can add new languages without editing translation files directly.
The process 🔗
- First, install the Localazy CLI, which helps you sync translations between your project and the platform.
npm install -g @localazy/cli
The command above installs Localazy globally so you can use it anywhere in your terminal.
2. Next, you need to create a localazy.json
file in the root of your project. This file defines which translation files to upload, the source language, and where to store downloaded translations. Instead of specifying just one file, you can configure it to handle multiple files using an array, which is useful for projects with structured translation files.
{
"writeKey": "YOUR_WRITE_KEY_HERE",
"readKey": "YOUR_READ_KEY_HERE",
"upload": {
"files": [
"public/locales/en.json",
{
"pattern": "public/locales/*.json"
}
],
"language": "en"
},
"download": {
"files": "public/locales/${lang}.json"
}
}
This setup ensures that all JSON translation files inside public/locales/ are automatically uploaded rather than needing to specify each file manually. If the project grows and you introduce namespaced translation files at some point (e.g., separate files for authentication, dashboard, or settings), Localazy will handle them without extra configuration.
3. Now that Localazy is configured, upload your source keys to make them available for translation via localazy upload
.
This command sends public/locales/en.json
to Localazy, where translators, team members, or machine translation tools can begin adding translations.

4. Once the upload is complete, you can proceed to add new languages. Localazy provides several ways to translate content:
- 📝 Manually enter translations for each language.
- 🦾 Use automatic machine translation to generate translations instantly.
To apply MT, select the language you want to translate into and click the machine translation option.

Then, choose your preferred translation service and click Confirm.

5. When the texts are translated, you can review them to verify their accuracy before confirming them and downloading them into your project with localazy download
.

This pulls all available translations from Localazy and places them in the /public/locales/
directory. If French (fr
) and German (de
) translations exist, the directory structure will now look like this:
/public/locales
├── en.json
├── fr.json
├── de.json
At this point, i18next
will automatically detect the user's preferred language and load the correct translations without additional configuration.
💬 How to switch between languages 🔗
Now that your app supports multiple languages, you need a way for users to switch between them. Since i18next handles language changes dynamically, you can simply update the language setting when a user selects a different language.
Modify App.jsx
to include language-switching buttons:
import { Suspense } from "react";
import "./App.css";
import { useTranslation, Trans } from "react-i18next";
const lngs = {
en: { nativeName: "English" },
fr: { nativeName: "French" },
de: { nativeName: "German" },
};
function App() {
const { t, i18n } = useTranslation();
return (
<div className="App">
<h1>{t("news.title")}</h1>
<div>
{Object.keys(lngs).map((lng) => (
<button
key={lng}
style={{ fontWeight: i18n.resolvedLanguage === lng ? "bold" : "normal" }}
type="button"
onClick={() => i18n.changeLanguage(lng)}
>
{lngs[lng].nativeName}
</button>
))}
</div>
<article>
<h2>{t("news.article.title")}</h2>
<p>{t("news.article.content")}</p>
</article>
<p>
<Trans i18nKey="instructions">
To learn more, visit <a href="https://reactjs.org">React docs</a>.
</Trans>
</p>
</div>
);
}
export default function WrappedApp() {
return (
<Suspense fallback="...loading translations">
<App />
</Suspense>
);
}
In the code above, an object stores the available language options and is looped through to generate buttons dynamically. When a user clicks a button, i18n.changeLanguage(lng)
updates the language instantly.

🎲 Handling pluralization and date formatting 🔗
Pluralization and date formatting can vary significantly across languages. While i18next can automatically handle pluralization in many cases, you often need to integrate additional tools or plugins like ICU or Luxon for more complex scenarios or date formatting.
Next, you'll learn how to handle both properly in a React project.
Pluralization with i18next 🔗
i18next can determine the correct plural form based on the language's grammar rules if your keys are structured correctly. For example, here's a simple pluralization case in the en.json
translation file:
{
"news": {
"articlesRead_one": "You have read one article.",
"articlesRead_other": "You have read {{count}} articles."
}
}
In the React component, t()
is then used to retrieve the appropriate translation based on the count:
<p>{t("news.articlesRead", { count: 1 })}</p> // "You have read one article."
<p>{t("news.articlesRead", { count: 5 })}</p> // "You have read 5 articles."
Here, i18next automatically picks the correct plural form ( _one
or _other
) based on the count passed. This works fine for languages that only have two plural forms (singular and plural), like English.
However, some languages have more than two plural forms, making it difficult to rely on the default _one and _other keys. For example, languages like Russian and Arabic have additional plural forms for different quantities, such as few, many, or other.
You can use ICU message syntax to handle languages with more than two plural forms. This allows you to define all plural forms in a single key and let i18next choose the correct form dynamically.
First, install the i18next-icu plugin, which enables ICU syntax support in i18next:
npm install i18next-icu
In the i18n.js
file, use the ICU plugin by adding it to the i18next initialization:
import ICU from "i18next-icu";
i18n.use(ICU).init({
fallbackLng: "en",
debug: true
});
Instead of defining separate keys for _one
and _other
, you now use ICU message syntax to handle pluralization. This makes it easier to manage languages with more plural forms:
{
"news": {
"articlesRead": "You have read {count, plural, one {one article} other {# articles}}."
}
}
The React component remains the same, but i18next will now automatically handle plural forms based on the user's language.
Date formatting with Luxon 🔗
Just like pluralization, date formats differ across regions. While JavaScript's Intl.DateTimeFormat
can handle basic date formatting, Luxon is used in this article because it provides better control over formatting, time zones, and locale-based adjustments.
To use Luxon, install it in your project:npm install luxon
.
Next, add a custom "formatter" in i18n.js to use Luxon for date formatting. Here's how to do it:
import { DateTime } from 'luxon';
// other config
i18n.services.formatter.add('DATE_HUGE', (value, lng, options) => {
return DateTime.fromJSDate(value)
.setLocale(lng)
.toLocaleString(DateTime.DATE_MED);
});
export default i18n;
You can now use the custom date format in your translation file:
{
"footer": {
"date": "Today is {{date, DATE_HUGE}}"
}
}
In the component, use t()
to inject the current date into the translation:
<footer>
<p>{t("footer.date", { date: new Date() })}</p>
</footer>
Luxon will automatically handle the localization of the date format, making sure it displays correctly in the user's preferred language.
🔍 Testing for multilingual readiness 🔗
Proper internationalization of your application involves more than adding translations. It also involves testing to prevent broken translations, missing keys, layout issues, and incorrect pluralization or date formatting.
One of the most common issues in i18n is missing translations. i18next provides a built-in way to log missing translations, but you can take it a step further by automating the detection process. To log missing translations in the console, update i18n.js
:
i18n.init({
fallbackLng: "en",
debug: true, // Enable debug mode
saveMissing: true, // Logs missing keys in development
});
Now, if a translation is missing, i18next will log a warning like this:
i18next::translator: missingKey en translation news.articleTitle
This helps you catch missing keys during development before they become a problem in production. However, instead of manually checking logs, you can write a Jest test to confirm that all English translations exist in en.json:
import i18n from "../src/i18n";
import en from "../public/locales/en.json";
test("All English translations exist", () => {
Object.keys(en).forEach((key) => {
expect(i18n.t(key)).not.toBe(key); // If t(key) returns the key itself, it's missing
});
});
For this test to work, install Jest and related testing libraries if you haven't already:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
With Jest set up, you can also test pluralization rules to ensure they always return the correct form. If a translation is missing or incorrectly structured, the test will fail.
test("Pluralization works correctly", () => {
expect(i18n.t("news.articlesRead", { count: 1 })).toBe("You have read one article.");
expect(i18n.t("news.articlesRead", { count: 5 })).toBe("You have read 5 articles.");
});
Another important check is making sure that language switching updates the UI correctly. Using React Testing Library, you can simulate a user changing languages and verify that the correct translations appear.
import { render, screen, act } from "@testing-library/react";
import App from "../src/App";
import i18n from "../src/i18n";
test("Switching language updates translations", async () => {
render(<App />);
expect(screen.getByText("Latest News")).toBeInTheDocument(); // English by default
await act(() => i18n.changeLanguage("fr"));
expect(screen.getByText("Dernières Nouvelles")).toBeInTheDocument(); // French translation appears
});
This confirms that translations update correctly when the user changes the language.
To prevent i18n issues from reaching production, these tests should be integrated into GitHub Actions or another CI/CD pipeline. In GitHub Actions, update .github/workflows/tests.yml to include i18n tests:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
- run: npm test
If any i18n test fails, the build stops, preventing broken translations from reaching users.
✔️ Final thoughts 🔗
Internationalization should be part of your development process from the start and not something that's added later. ☝️ Setting it up early saves time and avoids messy rewrites when you scale and need to support more languages.
To keep translations up to date, use CI/CD to sync with a translation management system like Localazy. This way, new keys are pushed automatically, and updated translations are pulled without manual effort.
Since i18next loads translations dynamically, adding a new language is as simple as enabling it in Localazy. Once synced, the translations will be available in your app without changing any code. With this setup, scaling to new markets is just a matter of adding translations, not rebuilding your project.