How to Localize a React App (and Next.js): A Practical Guide

7 min read
by Arvid Andersson
React Next.js i18n i18next react-i18next Localization
How to Localize a React App (and Next.js): A Practical Guide

Introduction

If you're building a React app today, the most common i18n stack is react-i18next on top of i18next. It works across Vite, CRA, Remix, Gatsby, and custom React setups, and gives you a practical API for day-to-day product development.

For Next.js App Router projects, next-intl is still a strong alternative because it's built around Server Components and locale-aware routing. But for React broadly, react-i18next is usually the default starting point.

This guide is primarily about react-i18next: how to set it up, structure messages, handle plurals and interpolation, scale to more locales, and avoid common pitfalls.

Choosing the Right Library

Here's the quick comparison:

Library Best for Server Components Locale routing Format
react-i18next React apps across frameworks Partial Manual setup JSON
next-intl Next.js App Router Yes (native) Built-in middleware JSON (ICU)
react-intl (FormatJS) ICU-heavy projects No Manual setup JSON (ICU)

react-i18next is a good default when your priority is flexibility across React environments. next-intl is often the better fit when your app is deeply tied to Next.js App Router patterns.

How react-i18next Works

The architecture has four pieces:

At a practical level, you keep one JSON file per language (often split by namespace), with one key per translatable string.

  1. Message files: JSON translation files per locale (and often per namespace)
  2. i18next config: language fallback, resources/backend loading, interpolation behavior
  3. React binding: react-i18next hooks/components (useTranslation, Trans)
  4. Language selection: browser detection, stored preference, or route-based locale

The flow looks like this:

React component -> t("key") -> i18next resolves key in active language -> rendered string

Setting It Up From Scratch

1. Install Dependencies

npm install i18next react-i18next i18next-browser-languagedetector

2. Create Message Files

src/locales/
├── en/
│   ├── common.json
│   └── home.json
└── sv/
    ├── common.json
    └── home.json
// src/locales/en/common.json
{
  "navigation": {
    "home": "Home",
    "about": "About",
    "pricing": "Pricing"
  },
  "actions": {
    "save": "Save",
    "cancel": "Cancel"
  }
}
// src/locales/sv/common.json
{
  "navigation": {
    "home": "Hem",
    "about": "Om oss",
    "pricing": "Priser"
  },
  "actions": {
    "save": "Spara",
    "cancel": "Avbryt"
  }
}

3. Configure i18next

// src/i18n.ts
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";

import commonEn from "./locales/en/common.json";
import homeEn from "./locales/en/home.json";
import commonSv from "./locales/sv/common.json";
import homeSv from "./locales/sv/home.json";

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { common: commonEn, home: homeEn },
      sv: { common: commonSv, home: homeSv }
    },
    fallbackLng: "en",
    defaultNS: "common",
    ns: ["common", "home"],
    interpolation: {
      escapeValue: false
    },
    detection: {
      order: ["querystring", "localStorage", "navigator"],
      caches: ["localStorage"]
    }
  });

export default i18n;

4. Load i18n Before Render

Import ./i18n once in your app entrypoint so translations are ready before components call useTranslation().

// src/main.tsx (Vite)
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./i18n";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Using Translations

Basic Usage

import { useTranslation } from "react-i18next";

export function Header() {
  const { t } = useTranslation("common");

  return (
    <nav>
      <a href="/">{t("navigation.home")}</a>
      <a href="/about">{t("navigation.about")}</a>
    </nav>
  );
}

Variable Interpolation

// src/locales/en/home.json
{
  "welcome": "Welcome back, {{name}}!"
}
const { t } = useTranslation("home");

<p>{t("welcome", { name: user.name })}</p>;

Pluralization

// src/locales/en/home.json
{
  "cart_items_one": "You have {{count}} item",
  "cart_items_other": "You have {{count}} items"
}
const { t } = useTranslation("home");

<p>{t("cart_items", { count: itemCount })}</p>;

i18next selects the right plural form based on count and locale rules.

Rich Text With Trans

{
  "terms": "By signing up, you agree to our <0>Terms of Service</0>."
}
import { Trans } from "react-i18next";

<Trans
  i18nKey="terms"
  ns="home"
  components={[<a key="0" href="/terms" />]}
/>;

Language Switching

A basic locale switcher:

import i18n from "./i18n";
import { useTranslation } from "react-i18next";

export function LanguageSwitcher() {
  const { i18n: i18nInstance } = useTranslation();

  return (
    <select
      value={i18nInstance.language}
      onChange={(e) => i18n.changeLanguage(e.target.value)}
    >
      <option value="en">English</option>
      <option value="sv">Svenska</option>
    </select>
  );
}

If you use i18next-browser-languagedetector, the choice can be persisted automatically in local storage or cookies.

Lazy Loading Messages at Scale

For larger apps, loading all translations up front can hurt startup performance. A common pattern is loading locale files on demand with i18next-http-backend.

npm install i18next-http-backend
import i18n from "i18next";
import Backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";

i18n
  .use(Backend)
  .use(initReactI18next)
  .init({
    fallbackLng: "en",
    ns: ["common", "home"],
    defaultNS: "common",
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json"
    }
  });

This keeps initial bundles smaller and scales better when you add languages and features.

Date, Time, and Number Formatting

react-i18next handles translation strings. For date/number/currency formatting, use the browser Intl API:

const date = new Date("2026-03-05");

new Intl.DateTimeFormat("sv-SE", {
  year: "numeric",
  month: "long",
  day: "numeric"
}).format(date);
// 5 mars 2026

new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
}).format(1499.9);
// $1,499.90

Next.js App Router Note

If your app is primarily Next.js App Router, next-intl is often easier because it provides a built-in model for locale routing, middleware-based detection, and Server Component usage.

In short:

  • React across environments: react-i18next
  • Next.js App Router-first: next-intl

Common Gotchas

1. Hardcoded UI Strings

It's easy to leave English strings directly in JSX. A good rule: if users see it, it should go through t().

2. Missing Namespace or Key

If keys resolve to raw key paths, check namespace usage (useTranslation("home")) and whether the JSON file is loaded for that locale.

3. Plural Keys Not Matching i18next Conventions

Plural keys should follow expected suffixes (_one, _other, etc.) and be called with a count option.

4. i18n Not Initialized Before Render

If you forget to import ./i18n in your entrypoint, components can render before i18next is ready.

5. App Locale and UI Locale Drifting Apart

If your app state (route locale, stored user preference, or browser-detected locale) says one language but your i18n instance is set to another, you'll get inconsistent UX. Keep your app-level locale source and i18n locale in sync.

Automating Your Translation Workflow

Setting up the i18n framework is one thing. Keeping translations in sync across 5, 10, or 30 languages as your app evolves is another challenge entirely.

The typical pain point: a developer adds new keys in English, but the Swedish, German, and French locale files don't get updated until someone manually notices. By then, you've shipped with missing translations. This is the exact problem I'm building Localhero.ai to solve.

The second pain point is consistency. Even when translations exist, they can drift from your product voice, naming conventions, and tone across features. For product teams, that matters as much as coverage.

A few ways to handle this:

  • Linting and CI checks catch missing keys and obvious mistakes, but don't generate translations
  • Human translators deliver quality but introduce delays, and new features can wait days for translations
  • Build your own LLM pipeline gives you full control, but you have to maintain prompts, quality checks, and infrastructure, and still provide a workflow for non-technical teammates to review and adjust translations
  • AI-powered tools like Localhero.ai integrate directly into your GitHub workflow, translating new keys automatically on every pull request while following your glossary, brand voice, and style-guide rules

The goal is to make translations part of the development flow, not a separate step. For a detailed CI/CD setup, see my GitHub Actions guide.

FAQ

Should I use react-i18next or next-intl for my Next.js project? If you're on Next.js App Router, next-intl is usually the better fit. If you're on Pages Router or building framework-agnostic React code, react-i18next is often the better default.

Can I use ICU message format with react-i18next? Yes. You can add ICU support with plugins like i18next-icu if your project needs ICU-style messages.

How do I handle right-to-left (RTL) languages like Arabic? Set dir="rtl" on your root element for RTL locales. Maintain a locale-to-direction mapping in your app and switch it when the language changes.

What about TypeScript support? react-i18next supports TypeScript and can be configured for typed keys using i18next's CustomTypeOptions.

How do I translate CMS or database content? Treat that as content localization, separate from UI key localization. Usually this is handled in your CMS/database layer, while react-i18next handles static UI text.

Cover photo by Timothy Cuenat on Unsplash.

Ready to Ship Global?

Try Localhero.ai's AI-powered translation management for your project. Start shipping features without translation delays.

No credit card required • Setup in a few minutes • Cancel anytime