Every React or Vue team that ships to more than one country eventually hits the same wall — translation strings scattered across components, no plural rules, and a bundle that loads every language up front. The fix looks different depending on which framework you picked. React developers reach for react-i18next, now at version 17.0.11, built on top of the framework-agnostic i18next core (currently v26.3.6). Vue developers reach for vue-i18n, now at 11.4.8 for Vue 3, with a 12.0.0-alpha.4 pre-release already in testing. Both solve the same problem, pluralization, interpolation, lazy-loaded translation namespaces, TypeScript autocompletion for translation keys, but the setup steps, file structure, and developer experience diverge enough that copying a React i18n tutorial into a Vue project (or vice versa) just does not work.
This tutorial walks through a complete, working internationalization setup for both stacks side by side: React 19.2 with react-i18next, and Vue 3.5 with vue-i18n. By the end you will have a language switcher, lazy-loaded JSON translation files, pluralization rules, and a TypeScript-safe translation key system running in both frameworks, plus the pitfalls that trip up most teams the first time they add a second language.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why i18n Setup Differs Between React and Vue
React has no built-in translation system. Every React i18n library, including react-i18next, next-intl, and Lingui, is a third-party addition that has to be wired into the component tree by hand, usually through a provider component and a set of hooks. Vue took a different path: vue-i18n was built by the Vue core-adjacent team at Intlify specifically to plug into Vue’s reactivity system and its Composition API, which is why it ships with native ICU MessageFormat support and built-in lazy loading without a separate backend plugin.
This is not a minor implementation detail. React’s component model is intentionally unopinionated about anything outside rendering: routing, data fetching, forms, and translation are all left to userland libraries by design. That is also why the ecosystem produces multiple competing i18n solutions (react-i18next, next-intl, react-intl, Lingui) instead of one obvious default. Vue ships opinions on more of the stack out of the box through its official packages (Vue Router, Pinia, and vue-i18n), so choosing an i18n library in a Vue project is a much shorter conversation than in React, where the “right” choice depends heavily on whether the app is a Vite SPA, a Next.js App Router project, or a React Native app.
That architectural difference shows up immediately in adoption numbers. react-i18next reports roughly 15.9 million weekly npm downloads according to npm’s own download statistics, though Snyk’s July 2026 snapshot puts the figure as high as 13.9 million depending on measurement window, and the i18next/react-i18next repository sits at around 10,000 GitHub stars. vue-i18n’s Snyk-tracked weekly downloads sit near 3.0 million as of its August 2026 snapshot. React’s ecosystem is larger in absolute terms because React itself has a bigger install base, but the per-project overhead of setting up i18next is noticeably higher than wiring up vue-i18n, mostly because React needs three separate packages (i18next, react-i18next, and a backend plugin like i18next-http-backend) where Vue needs one.
Neither library is going away. Vuex, the older Vue state library, was formally put into maintenance mode after Vuex 5 was cancelled in favor of Pinia, but there is no equivalent shakeup pending for either i18next or vue-i18n. Both are mature, both ship regular patch releases, and both are safe long-term bets for a production app in September 2026.
Prerequisites and Versions
Before starting, make sure your environment matches these versions. Mismatched major versions are the single most common cause of i18n setup failures reported in GitHub issues for both libraries.
| Tool | Minimum Version | Recommended (Sept 2026) | Notes |
|---|---|---|---|
| Node.js | 18.x | 22.x LTS | Required for both Vite and the CLI tooling |
| React | 18.x | 19.2.8 | Hooks-based react-i18next needs React 16.8+ |
| Vue | 3.3.x | 3.5.x (3.6 release candidate available) | vue-i18n 9+ requires Vue 3’s Composition API |
| i18next | 23.x | 26.3.6 | Framework-agnostic core engine |
| react-i18next | 14.x | 17.0.11 | React bindings on top of i18next |
| vue-i18n | 9.x | 11.4.8 | Vue 3 bindings, native ICU support |
| Vite | 5.x | 6.x | Used for both example projects below |
| TypeScript | 5.0+ | 5.6+ | Needed for type-safe translation keys |
You will also need a package manager (npm, pnpm, or yarn, this tutorial uses npm for consistency), a text editor with TypeScript support, and roughly two language JSON files to translate into. The examples below use English and Spanish, but the same steps apply to any language pair.
Step 1: Scaffold Both Projects
Start with two clean Vite projects so the comparison is apples-to-apples. Run these in separate directories.
# React project
npm create vite@latest react-i18n-demo -- --template react-ts
cd react-i18n-demo
npm install
# Vue project (in a separate terminal/directory)
npm create vite@latest vue-i18n-demo -- --template vue-ts
cd vue-i18n-demo
npm install
Both commands generate a TypeScript-ready project with Vite’s dev server pre-configured. Confirm each project runs with npm run dev before moving on. Debugging i18n errors on top of a broken scaffold wastes far more time than it should.
Step 2: Install the i18n Packages
React needs three packages: the core i18next engine, the react-i18next bindings, and a backend plugin for loading translation files lazily instead of bundling them all upfront. Vue needs one package.
# React
npm install [email protected] [email protected] i18next-http-backend i18next-browser-languagedetector
# Vue
npm install [email protected]
The two extra React packages matter: i18next-http-backend lets translation JSON files load on demand from the public folder instead of being bundled into the main JavaScript chunk, and i18next-browser-languagedetector automatically picks the user’s browser language on first visit. vue-i18n handles both of these natively without extra dependencies, which is the main reason its total install footprint stays smaller even though its own package is not dramatically lighter than i18next core on its own.
Step 3: Structure Your Translation Files
Both setups benefit from splitting translations into namespaces, separate JSON files per feature area (common, dashboard, checkout) rather than one giant file. This is what makes lazy loading useful later: a user who never visits checkout never downloads the checkout translations.
public/
locales/
en/
common.json
dashboard.json
es/
common.json
dashboard.json
This structure works identically for both React and Vue projects, copy the same locales folder into each project’s public directory. A sample common.json file for English looks like this:
{
"greeting": "Hello, {{name}}!",
"itemCount_one": "You have {{count}} item",
"itemCount_other": "You have {{count}} items",
"nav": {
"home": "Home",
"dashboard": "Dashboard"
}
}
Note the itemCount_one and itemCount_other keys, that is i18next’s plural suffix convention. vue-i18n handles pluralization differently, using a pipe-separated string within a single key instead of separate keys, which is one of the first syntax differences you will hit when porting content between the two systems.
How many namespaces a project needs depends on team size more than app size. A three-person team can usually get away with two or three namespaces (common, and one per major page area) without much friction. Larger teams working on the same app in parallel benefit from a finer split, with one namespace per feature team owning its own strings, because it reduces merge conflicts in translation JSON files and makes it obvious which team is responsible for keeping a given set of strings accurate when copy changes. The tradeoff is more files to manage and more useTranslation() / useI18n() calls scattered through components that need strings from more than one namespace at once, so there is a real point of diminishing returns past six or seven namespaces for most mid-sized apps.
Step 4: Configure i18next for React
Create an src/i18n.ts file that initializes i18next with the HTTP backend and language detector.
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
ns: ['common', 'dashboard'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
Import this file once at the top of main.tsx, before the React root renders. The loadPath pattern is what enables lazy loading: i18next only fetches dashboard.json when a component actually calls useTranslation('dashboard'), not on initial page load.
Step 5: Configure vue-i18n for Vue
vue-i18n’s setup is more compact because lazy loading and reactivity are built into the core plugin rather than layered on with a separate backend package.
// src/i18n.ts
import { createI18n } from 'vue-i18n';
async function loadLocaleMessages(locale: string) {
const common = await fetch(`/locales/${locale}/common.json`).then(r => r.json());
return { common };
}
const messages = { en: await loadLocaleMessages('en') };
export const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages,
});
export async function loadLocale(locale: string) {
if (!i18n.global.availableLocales.includes(locale)) {
const msgs = await loadLocaleMessages(locale);
i18n.global.setLocaleMessage(locale, msgs);
}
i18n.global.locale.value = locale as any;
}
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './i18n';
createApp(App).use(i18n).mount('#app');
The legacy: false flag is important because it switches vue-i18n into Composition API mode, which is what unlocks the useI18n() hook-style API instead of the older Options API global properties. Most vue-i18n tutorials still floating around the web default to legacy mode, which is why teams following outdated guides often end up unable to use useI18n() inside <script setup> components.
Step 6: Use Translations Inside Components
With both configs in place, consuming translations inside a component takes one hook in each framework, though the syntax differs.
// React: src/components/Greeting.tsx
import { useTranslation } from 'react-i18next';
export function Greeting({ name, itemCount }: { name: string; itemCount: number }) {
const { t } = useTranslation('common');
return (
{t('greeting', { name })}
{t('itemCount', { count: itemCount })}
);
}
<!-- Vue: src/components/Greeting.vue -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{ name: string; itemCount: number }>();
const { t } = useI18n();
</script>
<template>
<div>
<p>{{ t('common.greeting', { name }) }}</p>
<p>{{ t('common.itemCount', itemCount) }}</p>
</div>
</template>
i18next’s t() function takes the count as part of an options object ({ count: itemCount }) and resolves the plural suffix automatically. vue-i18n’s t() accepts the count as a positional argument when the message uses pipe-delimited plural syntax. Both approaches work, but they are not interchangeable. Copying i18next-style plural JSON into a vue-i18n project silently breaks pluralization instead of throwing an error, which is one of the pitfalls covered below.
Step 7: Build a Language Switcher
A language switcher is the component every i18n tutorial needs and most skip. Here is a minimal, working version for each framework.
// React: src/components/LangSwitcher.tsx
import { useTranslation } from 'react-i18next';
export function LangSwitcher() {
const { i18n } = useTranslation();
return (
);
}
<!-- Vue: src/components/LangSwitcher.vue -->
<script setup lang="ts">
import { loadLocale } from '../i18n';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
async function switchLang(e: Event) {
const value = (e.target as HTMLSelectElement).value;
await loadLocale(value);
}
</script>
<template>
<select :value="locale" @change="switchLang">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</template>
React’s version is simpler because i18next already tracks available languages and detected preferences internally. Vue’s version needs the custom loadLocale helper from Step 5 because vue-i18n does not ship an automatic HTTP backend. You own the fetch logic, which gives more control but also more boilerplate.
Step 8: Add TypeScript Autocompletion for Translation Keys
Typo’d translation keys are the most common production bug in any i18n system. The string just silently falls back to the key name itself, and nobody notices until a user reports a page full of raw dashboard.welcomeMessage text. Both ecosystems solve this with generated types.
// React: src/@types/i18next.d.ts
import 'i18next';
import common from '../../public/locales/en/common.json';
import dashboard from '../../public/locales/en/dashboard.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
dashboard: typeof dashboard;
};
}
}
For Vue, vue-i18n 11 supports schema-based typing by passing a generic type to createI18n, built from the same JSON import pattern. Both approaches turn a typo in t('dashbord.title') into a compile-time TypeScript error instead of a silent runtime fallback, which is worth the ten minutes of setup on any team larger than one developer.
Step 9: Handle Pluralization and ICU Rules Correctly
English has two plural forms (one, other). Many languages have more: Russian has three, Arabic has six. Both i18next and vue-i18n support the Unicode CLDR plural rules that handle this automatically, but the syntax to invoke them differs enough that this is the step most teams get wrong on their first non-English language.
i18next uses key suffixes (_zero, _one, _two, _few, _many, _other) matched against the Unicode CLDR plural category for the active language. vue-i18n uses inline pipe syntax within a single string, for example "no items | one item | {count} items", and its comparison documentation describes it as having native ICU MessageFormat support, meaning it can also embed select and gender logic directly in the string without a plugin. This is a genuine feature gap in Vue’s favor for teams doing heavy multi-language work with complex grammar rules, though it comes at the cost of the plural rule config being buried in the string rather than visible in the JSON key structure.
Testing plural output is worth doing by hand for at least one language outside the one-two-form family before shipping. Arabic’s six-category system (zero, one, two, few, many, other) is the one that most often exposes bugs, because a developer who only ever tests English and Spanish never triggers the “few” or “many” branches. A quick way to sanity check this in either library is to temporarily force the active locale to ar and cycle a counter component through 0, 1, 2, 6, 11, and 100. Those six values land in six different CLDR plural buckets and will surface a missing translation branch immediately if one exists.
Step 10: Format Dates, Numbers, and Currency
Translation strings are only half of internationalization. Dates, numbers, and currency need locale-aware formatting too, and neither library reinvents this. Both lean on the browser’s built-in Intl API.
// Works identically in both React and Vue components
const formatter = new Intl.NumberFormat(currentLocale, {
style: 'currency',
currency: 'USD',
});
formatter.format(49.99); // "$49.99" in en, "49,99 US$" in es
react-i18next exposes an optional formatParams helper that wraps Intl.NumberFormat and Intl.DateTimeFormat directly inside translation strings using {{value, currency}} syntax. vue-i18n has a dedicated $n and $d API (or n() and d() in Composition API mode) with named format presets defined once in the i18n config, which most teams find slightly cleaner for reuse across many components than repeating format options inline.
Step 11: Lazy-Load Namespaces on Route Change
The whole point of splitting translations into namespaces is to avoid shipping every string to every page. Both frameworks tie this into their router.
// React with react-router: load a namespace before rendering a route
import { useTranslation } from 'react-i18next';
function DashboardPage() {
const { t, ready } = useTranslation('dashboard');
if (!ready) return Loading…
;
return {t('title')}
;
}
Vue Router combined with the loadLocale pattern from Step 5 can prefetch a namespace inside a route’s beforeEnter guard, so the translation JSON is already resolved by the time the component mounts. Either approach keeps the initial bundle to just the common namespace, with feature-specific translations arriving only when a user actually navigates there, which mirrors the principle behind code-splitting a JavaScript bundle, just applied to translation data instead.
Step 12: Test Translations in CI
Missing translation keys should fail a build, not surface as raw key names in production. Both ecosystems support a “missing key” handler that can be wired into a test assertion.
// i18next: fail loudly on missing keys during CI
i18n.init({
saveMissing: true,
missingKeyHandler: (lng, ns, key) => {
throw new Error(`Missing translation: ${ns}:${key} for ${lng}`);
},
});
vue-i18n has an equivalent missingWarn and fallbackWarn config pair, and a missing handler function that can be pointed at the same kind of throw-on-missing logic. Pair either with a script that diffs the keys in en/common.json against every other locale file and fails CI if any key is missing from a translated language. This single check catches the majority of “the German page has English text” bugs before they reach production.
Step 13: Verify the Working Project
Run both dev servers and confirm the full loop: initial language detection, string interpolation, pluralization, and the language switcher round-trip.
npm run dev
# Visit localhost:5173, confirm:
# 1. Greeting renders with interpolated name
# 2. Item count shows correct singular/plural form
# 3. Switching to Spanish updates all visible text
# 4. Network tab shows lazy-loaded namespace JSON only after navigating
Expected output in the browser for the React demo, with name="Dana" and itemCount=3:
Hello, Dana!
You have 3 items
Switching the dropdown to Spanish should update both lines instantly without a page reload, and the Network tab in browser dev tools should show a request for /locales/es/common.json firing only at that moment, confirming lazy loading is working rather than every language being bundled up front.
Server-Side Rendering and SEO Considerations
Client-only lazy loading works fine for an authenticated dashboard, but a public marketing site or storefront needs translated content in the initial HTML response for search engines and social share previews to pick it up correctly. This changes the setup on both stacks. In a Next.js App Router project, react-i18next’s client-only hooks generally get swapped out for next-intl, which resolves translations on the server during the initial render and streams the correct language HTML before any JavaScript executes. Sticking with react-i18next in an SSR context is possible but requires manually hydrating the i18next instance with the detected language on the server and passing the resolved resources down as serialized props, which is exactly the kind of boilerplate next-intl exists to remove.
Nuxt, the SSR framework built on Vue, ships an official @nuxtjs/i18n module that wraps vue-i18n and handles the server-rendering piece automatically, including generating hreflang tags for each locale variant of a page and routing /en/ and /es/ URL prefixes to the correct translated content. For a plain Vite SPA without server rendering, none of this applies. Search engines will only ever see the language the JavaScript renders after it runs, so SSR-aware i18n only matters once a project needs to rank organically in more than one language, which is worth deciding before the project structure is locked in rather than after.
Performance Impact: What Lazy Loading Actually Saves
It is worth quantifying why the lazy-loading step in this tutorial matters rather than treating it as boilerplate. A translation file for a mid-sized dashboard feature (labels, form validation messages, tooltips, error strings) commonly runs 15 to 40 KB per language before compression. A project supporting five languages with that single namespace bundled eagerly ships up to 200 KB of translation JSON to every visitor, even the ones who only ever see one language in a session. Splitting that into per-feature namespaces and loading only the active language’s active namespace turns that into a handful of kilobytes on initial load, with the rest fetched incrementally as the user navigates.
The gap between react-i18next and vue-i18n here is not the runtime cost of fetching JSON, since both ultimately issue a browser fetch() call that costs the same either way. The gap is how much configuration is required to get lazy loading working correctly in the first place. React’s setup needs the i18next-http-backend plugin wired up correctly with a working loadPath pattern, which is an extra step that is easy to skip under deadline pressure, defaulting instead to statically importing every locale file at the top of the app. vue-i18n’s setLocaleMessage pattern shown in Step 5 makes the lazy path the natural one to write, since there is no bundled-by-default alternative baked into the basic tutorial examples circulating for the library.
Common Pitfalls
These are the mistakes that show up most often in i18n setup issues on GitHub and Stack Overflow for both libraries.
- Forgetting
legacy: falsein vue-i18n. Without it,useI18n()throws inside Composition API components, and the error message does not clearly point at the config flag as the cause. - Bundling all languages instead of lazy loading. Importing every locale JSON file directly at the top of the app defeats the entire purpose of namespace splitting and bloats the initial bundle for every user, including ones who only ever see one language.
- Mixing i18next plural key syntax into a vue-i18n project. The
_one/_othersuffix convention is meaningless to vue-i18n. It will render the literal key name instead of throwing an error, so the bug is invisible until QA catches it manually. - Not setting a
fallbackLng/fallbackLocale. Without a fallback, an incomplete translation file renders blank space instead of falling back to English, which is far more visible and worse for users than a partially-translated page. - Interpolating raw HTML without escaping controls. Both libraries can render HTML from translation strings (via
Transin react-i18next ori18n-tcomponent in vue-i18n), but doing this with user-supplied data instead of static translator-authored copy opens an XSS hole. - Loading translation files from a CDN without cache busting. Namespace JSON files change with every content update. Without a version query param or hashed filename, browsers can serve a stale cached translation indefinitely.
- Skipping the TypeScript key typing step. Teams that skip Step 8 report the same typo-key bug repeatedly in production because there is nothing catching it before deploy.
React vs Vue i18n: Feature Comparison
| Feature | react-i18next (17.0.11) | vue-i18n (11.4.8) |
|---|---|---|
| Core dependency | Requires separate i18next package | Self-contained, no core dependency |
| Lazy loading | Needs i18next-http-backend plugin | Built in natively |
| Language auto-detection | Needs i18next-browser-languagedetector plugin | Manual, via router or stored preference |
| Pluralization syntax | Key suffixes (_one, _other) | Pipe-delimited inline string |
| ICU MessageFormat | Not native (JSON key-based) | Native support |
| TypeScript key safety | Via declared module augmentation | Via generic type on createI18n |
| Bundle size (gzip, core lib) | ~9.0 KB (react-i18next alone) | Not independently verified in current sources |
| Weekly npm downloads | ~7.9M–13.9M (source-dependent) | ~3.0M |
| GitHub stars (bindings repo) | ~10,000 | Not verified in current sources |
| Date/number formatting | formatParams wrapper around Intl | Built-in $n / $d (n() / d()) API |
Troubleshooting
The most common errors teams hit while wiring up either library, and how to resolve each one.
- “useTranslation must be used within I18nextProvider” (React). Confirm the
i18n.tsinit file is imported beforeReactDOM.createRootruns inmain.tsx. Import order matters because i18next needs to initialize before any component calls the hook. - “useI18n() is called without provided by option” (Vue). This means
legacy: falsewas not set, or the app was mounted without.use(i18n)inmain.ts. - Translation keys render as raw strings like
common.greeting. The namespace was not loaded before the component rendered. Check thereadyflag fromuseTranslationin React, or confirm the asyncloadLocaleMessagescall resolved before mount in Vue. - 404 errors on
/locales/en/common.json. The file must live in thepublicfolder, notsrc. Vite only serves static assets frompublicat the site root without a build step. - Pluralization shows the wrong form for non-English languages. Verify the language code matches CLDR conventions exactly. Arabic needs six plural categories under
ar, and using a two-letter code without a region subtag can select the wrong rule set in i18next. - Language switcher works but page reverts on refresh. Neither library persists the selected language by default. Store the choice in
localStorageand read it back during initialization, or rely on i18next-browser-languagedetector’s built-in localStorage caching option. - TypeScript errors after adding the module augmentation file. Confirm the
.d.tsfile is included intsconfig.json‘sincludearray. Vite’s default TypeScript config sometimes excludes files outsidesrc. - Interpolated HTML not rendering (shows as literal
<b>tags). Use theTranscomponent in react-i18next or thei18n-tcomponent in vue-i18n instead of the plaint()function. The plain function escapes HTML by design to prevent injection.
Advanced Tips
Once the base setup works, a few refinements make the system easier to maintain as an app grows past two languages.
First, automate missing-key detection with the newer i18next-cli tool, which scans source code for t() calls and diffs them against your JSON files. That catches keys referenced in code but never added to translation files, the inverse of the CI check from Step 12. Second, if your React app is on Next.js rather than plain Vite, consider next-intl instead of react-i18next. It is server-first and designed around the App Router, and its adoption is already at roughly 900,000 weekly downloads according to PkgPulse’s comparison guide — a meaningful fraction of react-i18next’s install base for a much younger library. Third, for teams doing heavy compile-time optimization, Lingui takes a different approach entirely: it extracts translations at build time instead of runtime, and reports around 300,000 weekly downloads with the smallest resulting bundle size of the three, a worthwhile look if bundle size is the binding constraint. Finally, in Vue, consider Pinia for storing the active locale globally if more than a handful of components need to react to language changes outside the component tree that already has access to useI18n(). That avoids prop-drilling the locale value manually.
Complete Working Project Structure
By the end of this tutorial, each project should match this structure:
react-i18n-demo/
├── public/
│ └── locales/
│ ├── en/{common.json, dashboard.json}
│ └── es/{common.json, dashboard.json}
├── src/
│ ├── @types/i18next.d.ts
│ ├── components/
│ │ ├── Greeting.tsx
│ │ └── LangSwitcher.tsx
│ ├── i18n.ts
│ └── main.tsx
└── package.json
vue-i18n-demo/
├── public/
│ └── locales/
│ ├── en/{common.json, dashboard.json}
│ └── es/{common.json, dashboard.json}
├── src/
│ ├── components/
│ │ ├── Greeting.vue
│ │ └── LangSwitcher.vue
│ ├── i18n.ts
│ └── main.ts
└── package.json
Both projects now support lazy-loaded, TypeScript-safe, pluralization-aware translations with a working language switcher: the same feature set, built with each framework’s idiomatic tools rather than forcing one library’s patterns onto the other stack.
Right-to-Left Languages and Layout Direction
Adding Arabic, Hebrew, or Farsi introduces a problem neither i18next nor vue-i18n solves on its own: layout direction. Translation strings can be correct while the page still reads left-to-right, which breaks navigation, form alignment, and icon placement for right-to-left readers. Both frameworks need the dir attribute set on the document root based on the active locale, typically alongside the language switcher logic already built in Step 7.
// Works in either framework, called whenever the locale changes
const rtlLocales = ['ar', 'he', 'fa'];
document.documentElement.dir = rtlLocales.includes(currentLocale) ? 'rtl' : 'ltr';
document.documentElement.lang = currentLocale;
In React, this is usually placed inside a useEffect that watches i18n.language. In Vue, a watch on the locale ref from useI18n() does the same job. Neither react-i18next nor vue-i18n ships CSS logical properties automatically, so a project targeting RTL languages should also audit its stylesheet for hardcoded left/right and margin-left/margin-right rules and replace them with logical equivalents (inset-inline-start, margin-inline-start) that flip automatically with the dir attribute. That CSS-level fix is not something either i18n library handles for you, but skipping it breaks the layout visibly.
Which One Should You Choose?
If you are already committed to React, react-i18next remains the default choice for anything outside a pure Next.js App Router project, where next-intl is increasingly the better fit. Its larger ecosystem means more Stack Overflow answers, more plugin coverage, and battle-tested behavior across a wider range of edge cases, even though it costs more setup boilerplate up front. If you are on Vue 3, vue-i18n is close to the only serious option, and its native ICU support plus zero-dependency lazy loading make the setup meaningfully faster to get right on the first attempt. Neither library is a reason to pick one framework over the other on its own. Both are mature, well-maintained, and capable of shipping a fully localized production app, but the setup friction is real, and knowing where each one adds boilerplate before starting saves an afternoon of trial and error.
Frequently Asked Questions
Is react-i18next still the best i18n library for React in 2026?
For most non-Next.js React apps, yes. It remains the most widely adopted option with the deepest plugin ecosystem, though Next.js App Router projects are increasingly better served by next-intl, and teams optimizing hard for bundle size sometimes choose Lingui instead.
Does vue-i18n work with the Vue 2 Options API?
Older vue-i18n major versions supported Vue 2, but vue-i18n 11.x targets Vue 3 and its Composition API. Legacy mode (legacy: true) still exposes Options API-style global properties for teams migrating gradually, but new projects should use Composition API mode.
Can I use ICU MessageFormat in react-i18next?
Not natively. i18next uses its own JSON key-suffix convention for pluralization instead of ICU MessageFormat. Libraries like Lingui and FormatJS’s react-intl support ICU natively if that syntax is a hard requirement for your translation team’s existing workflow.
How do I lazy load translations without a backend server?
Both examples in this tutorial load static JSON files from the public folder over HTTP, which works with any static host or CDN. No backend server or API is required. i18next-http-backend and vue-i18n’s native fetch-based loading both just need the JSON files reachable at a URL.
What happens if a translation key is missing?
By default, both libraries fall back to rendering the key name itself or the fallback language’s string, depending on configuration. Setting up a CI check that diffs keys across locale files (Step 12) catches missing translations before they reach production instead of relying on the runtime fallback.
Do I need TypeScript for either of these setups?
No, both libraries work fine in plain JavaScript. TypeScript adds compile-time safety for translation keys (Step 8), which is optional but strongly recommended for any team larger than one developer, since it catches typo’d keys before they ship.
Is vue-i18n’s native lazy loading actually faster than react-i18next’s plugin-based approach?
Runtime performance is comparable since both ultimately fetch JSON over HTTP. The difference is setup overhead: vue-i18n needs no extra dependency to lazy load, while react-i18next requires installing and configuring i18next-http-backend separately to get the same behavior.
Can I migrate an existing react-i18next project’s translation files to vue-i18n?
The base key-value strings port over directly, but pluralized keys need to be rewritten from i18next’s suffix convention (_one, _other) into vue-i18n’s pipe-delimited inline syntax, since the two systems do not share a plural format automatically.





