mrkeyoor.com_
Thu 06 Aug 07:42 UTC
npmUtilsupdated 06 Aug 2026

i18next

i18next is the translation engine that sits under most JavaScript internationalization setups. You give it a nested object of translation strings per language, call i18next.init() once, and then everything is t('some.key'). It handles the parts that are annoying to hand-roll: interpolating variables into strings, picking the right plural form through Intl.PluralRules (so Polish and Arabic get their five and six forms, not just singular and plural), gendered or state-based variants through context, nesting one key inside another, and falling back down a chain of languages when a key is missing. The core ships with zero runtime dependencies and knows nothing about React, Vue or the DOM. Everything else is a plugin you register with .use(): a backend to fetch translation files over HTTP or read them off disk, a detector to guess the user's language from the URL, cookie or navigator, a post-processor, or a framework binding such as react-i18next.

Verdict

i18next is the safe default for JavaScript translation: the plural handling is correct, the plugin surface covers every runtime you are likely to hit, and the maintainers close bugs fast. The cost is a large configuration surface and runtime key resolution, so if you want compile-time extraction and ICU as first-class, Lingui is the better shape.

API stability3/5t(), init() and changeLanguage have looked the same for years and most upgrades are painless. The types are another story: the selector API arrived in 25.4, strictKeyChecks in 24.2, and 26.0 removed the legacy interpolation.format function and the initImmediate option outright. Major versions now land roughly annually, and the v3 to v4 plural JSON change still catches people migrating old translation files.
Docs4/5i18next.com is a full handbook with separate chapters for plurals, context, nesting, formatting and every plugin, plus per-framework guides. The gap is that options are documented individually rather than as interacting systems, so working out why a key with a dot in it resolves to nothing means already knowing that keySeparator exists.
Maintenance5/526.3.6 shipped on 9 July 2026 with the repo pushed the same day, and the tracker sits at 2 open issues (2 counting PRs) on a project that started in 2011. The changelog is unusually detailed, explaining the mechanism behind each fix, and security advisories such as GHSA-6jcc-5g8w-32mx are written up with the affected configuration spelled out.
Ecosystem5/5Around 20M weekly downloads and official bindings for React, Vue, Angular, Svelte and Next.js, plus backends for HTTP, filesystem, Chained, and locize. Funding comes from locize, the commercial translation service by the same authors, which keeps the project alive but also means the docs and README point at a paid product throughout.

Use it if

  • You need real plural rules, not an if count === 1 ternary: i18next routes through Intl.PluralRules so a key written as item_one plus item_other automatically grows the _few and _many forms Slavic and Arabic locales need
  • Your app spans more than one framework or has a Node side: the same instance, the same JSON files and the same t() work in the browser, in an Express handler and in a CLI, with bindings for React, Vue, Angular and Svelte on top
  • You want to load translations lazily per language and per namespace instead of shipping every locale in the main bundle, which is what i18next-http-backend plus namespaces are for
  • You are handing JSON files to translators or a translation management system and want a format with wide tooling support rather than something bespoke
Skip it if

Setup reality

npm install i18next gets you the core and nothing else, because the core has no runtime dependencies. A working browser setup is usually three packages: i18next, i18next-http-backend to fetch the JSON, and i18next-browser-languagedetector to pick the language, wired with .use(...).use(...).init(...). init() is asynchronous and returns a promise even when you pass resources inline, so calling t() before it resolves gives you the raw key back; render behind the promise or the initialized event. If you render with React, set interpolation.escapeValue to false, since React already escapes and the default double-escapes your text into HTML entities. Plural keys must use the v4 JSON format (key_one, key_other, key_many); files written for i18next v20 and earlier with key_plural will resolve to nothing and give you no warning unless you turn on saveMissing or a missingKeyHandler. TypeScript users need a d.ts that augments CustomTypeOptions with defaultNS and resources, otherwise t() accepts any string and you lose the entire point of the typed keys. On very large dictionaries the type inference gets slow enough to stall your editor, which is why v25.4 added enableSelector. v26 removed the old monolithic interpolation.format function and the initImmediate option, so upgrading from v23 or earlier is a real migration, not a version bump.

Patterns

Initialize with inline resourcesinitialize-instance

import i18next from 'i18next'

await i18next.init({
  lng: 'en',
  fallbackLng: 'en',
  defaultNS: 'common',
  resources: {
    en: { common: { greeting: 'Hello {{name}}' } },
    de: { common: { greeting: 'Hallo {{name}}' } },
  },
  interpolation: { escapeValue: false }, // React already escapes
})

i18next.t('greeting', { name: 'Ada' }) // 'Hello Ada'

init() is always asynchronous, so t() called before the promise resolves returns the key string. escapeValue defaults to true, which is correct for innerHTML but double-escapes inside React and turns an apostrophe into ' on screen.

Write plurals that work outside Englishplural-forms

// en/common.json
{ "item_one": "{{count}} item", "item_other": "{{count}} items" }

// pl/common.json  (Polish needs four categories)
{
  "item_one": "{{count}} przedmiot",
  "item_few": "{{count}} przedmioty",
  "item_many": "{{count}} przedmiotow",
  "item_other": "{{count}} przedmiotu"
}

i18next.t('item', { count: 3 })

The suffix set is decided by Intl.PluralRules for the active language, not by your English file, so a locale can need categories English never uses. Passing count is what triggers plural resolution at all; without it you get the base key and a miss. The old v3 key_plural suffix silently resolves to nothing on v26.

Pick a variant by context instead of branching in codecontext-variants

// friend, friend_male, friend_female, friend_male_other ...
{
  "friend": "A friend",
  "friend_male": "A boyfriend",
  "friend_female": "A girlfriend"
}

i18next.t('friend', { context: 'female' })          // 'A girlfriend'
i18next.t('friend', { context: 'nonbinary' })        // falls back to 'A friend'
i18next.t('friend', { context: 'female', count: 2 }) // looks up friend_female_other

Context and count combine into one suffix chain, so the plural forms of every context need their own keys. An unknown context falls back to the bare key rather than erroring, which is convenient at runtime and a good way to ship a silently wrong string.

Split translations into namespaces loaded on demandnamespaces-and-lazy-loading

import i18next from 'i18next'
import HttpBackend from 'i18next-http-backend'

await i18next.use(HttpBackend).init({
  lng: 'en',
  ns: ['common'],          // loaded at init
  defaultNS: 'common',
  backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
})

await i18next.loadNamespaces('checkout')
i18next.t('checkout:submit')
i18next.t('submit', { ns: 'checkout' })

Only the namespaces listed in ns are fetched at init; everything else needs loadNamespaces or a framework binding that suspends. The colon prefix form breaks if a key legitimately contains a colon, in which case set nsSeparator to false and always pass ns as an option.

Detect the user's language and switch it laterdetect-and-change-language

import LanguageDetector from 'i18next-browser-languagedetector'

await i18next.use(LanguageDetector).init({
  fallbackLng: 'en',
  supportedLngs: ['en', 'de', 'fr'],
  detection: { order: ['querystring', 'cookie', 'localStorage', 'navigator'] },
})

i18next.on('languageChanged', (lng) => { document.documentElement.lang = lng })
await i18next.changeLanguage('de')

Do not set lng when using the detector; a hardcoded lng wins and the detector does nothing. Without supportedLngs a browser reporting de-AT triggers a fetch for a de-AT file that does not exist before falling back. changeLanguage returns a promise that resolves once the new files are loaded, so await it before re-rendering.

Format numbers, dates and currency inside a stringformat-values

// "total": "You paid {{amount, currency}} on {{when, datetime}}"
i18next.t('total', {
  amount: 42.5,
  when: new Date(),
  formatParams: {
    amount: { currency: 'EUR' },
    when: { dateStyle: 'long' },
  },
})

// custom formatter
i18next.services.formatter.add('shout', (value) => String(value).toUpperCase())
// "hi": "{{name, shout}}"

The built-in number, currency, datetime, relativetime and list formatters are thin wrappers over Intl, so options go through formatParams keyed by variable name. v26 removed the old interpolation.format callback entirely; code still passing that function gets no formatting and no error. Use addCached instead of add when the formatter constructs an Intl object, so it is not rebuilt on every call.

Make t() reject keys that do not existtype-safe-keys

// i18next.d.ts
import 'i18next'
import common from './locales/en/common.json'
import checkout from './locales/en/checkout.json'

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common'
    resources: { common: typeof common; checkout: typeof checkout }
    strictKeyChecks: true
  }
}

This needs resolveJsonModule in tsconfig and the d.ts included in the program, otherwise it is silently inert and t() keeps accepting any string. strictKeyChecks also rejects a key that only exists via defaultValue, which is what you want in CI and irritating during a first pass at a feature.

Use the selector API when key types stall your editorselector-api

await i18next.init({ enableSelector: true, /* ... */ })

i18next.t(($) => $.checkout.submit)
i18next.t(($) => $.cart.item, { count: 3 })

// enableSelector: 'optimize' skips building the union of every key path
// at the type level, which is what keeps very large dictionaries usable

Added in 25.4 specifically because inferring a string union over a dictionary with thousands of keys makes TypeScript crawl. Selector mode does not mix with a custom keySeparator that differs from a dot, and the codemod published alongside it exists because converting a large codebase by hand is not realistic.

Avoid a shared language on the serverserver-instance-per-request

import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'

async function i18nFor(lng) {
  const instance = createInstance()
  await instance
    .use(resourcesToBackend((l, ns) => import(`./locales/${l}/${ns}.json`)))
    .init({ lng, fallbackLng: 'en', ns: ['common'] })
  return instance
}

The default export is a singleton, so calling changeLanguage on it in a request handler changes the language for every concurrent request. Create an instance per request, or use cloneInstance if the resource store is already loaded and you only need a different active language.

Test for a key before rendering itcheck-key-exists

if (i18next.exists('checkout.promoBanner')) {
  render(i18next.t('checkout.promoBanner'))
}

// or accept a fallback explicitly
i18next.t('checkout.promoBanner', { defaultValue: '' })

A missing key returns the key string itself, so an untranslated banner renders as checkout.promoBanner to the user rather than disappearing. exists() respects the fallback language chain, so it returns true when only the fallback has the key, which is usually what you want but not always.

Surface missing translations in developmentcatch-missing-keys

await i18next.init({
  debug: process.env.NODE_ENV !== 'production',
  saveMissing: true,
  missingKeyHandler: (lngs, ns, key, fallbackValue) => {
    console.warn(`missing ${ns}:${key} for ${lngs.join(',')}`)
    if (process.env.CI) process.exitCode = 1
  },
})

saveMissing must be on for missingKeyHandler to fire at all. Leave saveMissing on in production only if your backend implements a create endpoint you trust, because with a write-capable backend every typo posts a new key to your translation store.

Inject a translation bundle after initadd-resources-at-runtime

i18next.addResourceBundle('de', 'checkout', { submit: 'Absenden' }, true, false)

// deep: true merges into the existing namespace
// overwrite: false leaves existing keys alone

This is how plugin or micro-frontend code contributes its own strings without owning the init call. Passing deep and overwrite both true on data you did not author is the pattern behind advisory GHSA-6jcc-5g8w-32mx, fixed in 26.3.4; treat the bundle as untrusted input if it came off the network.

Alternatives

PackageRegistryPick it when
@lingui/corenpmYou want ICU MessageFormat, messages extracted from source by a CLI, and catalogs compiled at build time so missing keys fail CI instead of production
@formatjs/intlnpmICU MessageFormat is non-negotiable because your translation vendor or your other platforms already use it
vue-i18nnpmYou are on Vue and want message compilation, SFC i18n blocks and devtools integration that understand the framework
i18next-icunpmYou are keeping i18next but need it to parse ICU messages rather than its own interpolation syntax