This is a major breaking release. It changes defaults that most setups had to override, gives every instance its own logger and post-processors, refuses language codes that look like markup or paths, and removes options that were broken or had no effect. 26.7 warned in debug mode about most of the runtime changes. The migration guide has the details; to keep the v26 behavior where an option exists:
i18next.init({
fallbackLng: 'dev', // or better your source language: there is no implicit 'dev' any more
returnEmptyString: true, // an empty translation is a translation again
cleanCode: false, // keep codes without a region as they are ('EN' stays 'EN')
});declare module 'i18next' {
interface CustomTypeOptions {
strictInterpolationChecks: false;
returnEmptyString: true;
}
}Changed defaults
fallbackLngdefaults tofalseinstead of['dev']. Without afallbackLng, i18next no longer requestsdevfrom the backend (/locales/dev/translation.jsonwith i18next-http-backend), andsaveMissingsends missing keys for the current language instead ofdev. WithsupportedLngsand nofallbackLng, an unsupported language leaveslanguageandlanguagesunset, asfallbackLng: falsealready did. SetfallbackLngto your main language, or to'dev'for the previous behavior.returnEmptyStringdefaults tofalse: an empty translation falls back like a missing value (to the fallback language, the default value or the key), asnullalready does. i18next-cli writes''for untranslated keys in the other languages, which now fall back instead of rendering blank. An explicit''default from code still counts:t('key', '')andt('key', { defaultValue: '' })return''. SetreturnEmptyString: true(ininit()and inCustomTypeOptions) if an empty translation is meant to render nothing, for example for a Locize project with "preserve empty strings" on.- Language codes without a region get their canonical casing, as codes with one already did:
'EN'becomes'en'. Only the casing changes: aliases liketl,iworengstay as they are ('TL'becomes'tl'), and so do codes that are not BCP 47 (en_US,my_custom). i18next 26.7.0 and 26.7.1 warned that v27 turnsiwintoheortlintofil; that was wrong and 26.7.2 corrects it.iw,tland the like stay as they are. SetcleanCode: falseto keep codes without a region exactly as they are.cleanCodeis deprecated. - types:
strictInterpolationChecksdefaults totrue:t()needs the options for a string with variables, so a{{name}}cannot be left out of a call. Type a wrapper aroundt()withParseKeysWithoutVariables, declare the variablesinterpolation.defaultVariablesfills in withdefaultVariablesinCustomTypeOptions, or setstrictInterpolationChecks: falseto keep the v26 types (also for ICU and MF2 messages, whose arguments are required either way). The fixes below close the gap plural keys left. - types:
returnEmptyStringdefaults tofalseinTypeOptionstoo, so a key whose value is''is typed as the key.
Changed behavior
- The logger and the post-processors belong to the instance.
use()of a logger or a post-processor on one instance no longer changes the others, and theinit()of one instance no longer rewires the logging of all of them. An instance fromcreateInstance()does not see what the defaulti18nextinstance (or any other) registered: calluse()on each instance. A clone (cloneInstance()) keeps its parent's logger and post-processors, also those its parent registers later, and what it registers itself stays its own. A plugin that callsuse()from its owninit()(the Locize InContext editor) registers right away, as before.services.loggerstays the instance's logger, withforward(). In debug mode, apostProcessname that is not registered on the instance logs a warning. - An object or an array without
returnObjectsis handled like a missing key:t()returns the default value if one is passed, else the key (withappendNamespaceToMissingKey,parseMissingKeyHandlerand the post-processors applied), instead of the sentencekey '... (en)' returned an object instead of string., which ended up in the UI. It is not saved as missing, since the key exists. The debug warning stays, andreturnedObjectHandlerstill decides what is returned when it is set (returnedObjectHandler: (key) => `key '${key}' returned an object instead of string.`brings a sentence back). addResources()keeps every value: numbers, booleans and objects were dropped silently, only strings and arrays were added.- The default escape no longer escapes
/:'$10/month'stays'$10/month'instead of'$10/month', and dates in react-i18next'sTransno longer render as17/12/2025(discussion 2199).&,<,>,"and'are still escaped, which covers HTML text and quoted attributes; a/cannot end either. Update snapshots that contain/, or pass your owninterpolation.escapeto keep escaping it. - A language code from
lng,changeLanguage(), a language detector orloadLanguages()that does not have the shape ofisSafeLanguageCode()(letters, digits,-,_,@and., no.., at most 128 characters) is refused unless it is configured: listed insupportedLngs, in afallbackLngthat is not a function or inpreload, or a language inresources, as given toinit().changeLanguage()continues as with an unsupported code: with the detector's next code, else the fallback language, else none (withfallbackLng: false, a code refused atinit()leaveslanguageunset);loadLanguages()skips it. WithsupportedLngs, the input is matched as before ('de-DE;q=0.9'still gives'de'), and only an unsafe match is refused, which onlynonExplicitSupportedLngscan return ('de-<x>'for a supported'de'). WithoutsupportedLngs, a detector that returned"><img src=x onerror=alert(1)>made that stringi18next.language, which apps put into<html lang>, and backends were asked to load it. BCP 47, POSIX (en_US.UTF-8@euro,sr@latin) and private-use codes (en-x-pirate) pass; to use a code with other characters (+, spaces,/), list it insupportedLngs. In debug mode a refused code logs a warning. createInstance(options)merges the defaults right away, without initializing: beforeinit(),instance.optionshas the defaults,getFixedT()no longer throws (t()returns''untilinit()), anddebug: truealready shows the warnings beforeinit().init()still tells the options you set from the defaults, so akeySeparatorornsSeparatorpassed tocreateInstance()counts as set by you (#2426).loadNamespaces()beforeinit()does nothing and calls back, as it did for an instance without options.keySeparator: falsemeans flat keys only: a lookup no longer splits a key at the textfalse(t('nestedfalsec')returned the value ofnested.c), and a selector path is joined with.like a key prefix, so selectors work on the flat keys ($ => $['a.b']and$ => $.a.blook up the flat key'a.b';$ => $.nested.cno longer resolvesnested, thenc). types: withkeySeparator: falseinCustomTypeOptions, the keys are the flat keys (nonestedfalseckeys), a key containingfalseis no longer split, andkeyPrefixtakes the parts of the flat keys before a., which is howgetFixedT()joins it ('nested'for'nested.c'). A key prefix combined with selectors is not typed for flat keys.
Removed
- The dotted language in the resource store calls:
getResource('en.translation.key'),addResource('en.translation.key', value)andaddResourceBundle('en.translation', resources). Pass language, namespace and key as separate arguments;addResource(),addResources()andaddResourceBundle()throw aTypeErrorfor the dotted form. A language code with a.(de_DE.UTF-8) is now a language like any other. interpolation.prefixEscaped,suffixEscaped,nestingPrefixEscapedandnestingSuffixEscaped, which were inserted into the RegExp unescaped. Pass the literal delimiters asprefix,suffix,nestingPrefixandnestingSuffix.- types:
compatibilityJSON(it had no effect since v24). - types: the option types of plugins.
locizeLastUsedis declared by locize-lastused (5.1.0 and later).ReactOptionsstays importable from'i18next'but is empty: react-i18next fills it in (17.1.13 and later, and 18), so thereactoptions are checked when react-i18next is in the program. - types: the
_and$helpers intypescript/t.d.tsandtypescript/options.d.ts($FirstNamespace,_ValueOfand 84 more) are no longer importable; every declaration of those files counted as exported. The other types of those files (TOptionsBase,TFunctionSignature,ParseTReturn, ...) are now exported on purpose. If you imported one of the hidden helpers, please open an issue: exporting it again does not break anything. - The root
index.js, a CommonJS shim that theexportsmap made unreachable.
Support
- Node.js 22 or later (
engines); CI tests 22, 24 and 26. - The build targets ES2020 (Chrome and Edge 80, Firefox 80, Safari 14, Opera 67, Samsung Internet 13) instead of browserslist
defaults, which moved with every update of the browser data. The output is the same as 26.7's. - TypeScript
^5 || ^6 || ^7, unchanged.
Kept
- Backends and async language detectors without
callbackApistill work; i18next guesses their calling convention from the number of parameters and logs a deprecation warning in debug mode. isInitialized,initializedStoreOnceandinitializedLanguageOncestay writable, so react-i18next 17'suseSSRand next-i18next's Pages Router work with 27. Writing them is deprecated: callhydrate().services.backendConnector.backendstays readable.
Type fixes for strictInterpolationChecks
- fix(types): with
strictInterpolationChecks, a plural key no longer switches the check off. Withoutcounta plural key likeitem(item_one,item_other) has no value of its own, and thestringit resolved to hid the variables of every other key in a union:(key: 'greeting' | 'item') => t(key)and(key: ParseKeys<'ns'>) => t(key)compiled as soon as the namespace had a plural key, while the same wrapper without one failed. Now such a call is checked against the plural forms, ordinal ones and'ns:item'keys included:t('item')needscountwhen the forms use it, and a wrapper over a union with a plural key fails as it does without one. To migrate, passcount, or type the wrapper withParseKeysWithoutVariables. - fix(types): with
strictInterpolationChecks, a call with options needs the variables of a plural key's forms too:t('item', { defaultValue: 'x' })and(key: 'plain' | 'item') => t(key, { defaultValue: 'x' })needcountwhen the forms use it, ast('item')does. Before, any options object turned the check off for a plural key withoutcount. Without the flag the options types are unchanged. - fix(types):
ParseKeysWithoutVariablesleaves out a key whose ordinal forms takecount, and keeps a key that has its own value next to its plural forms, sincet()returns that value withoutcount. - fix(types):
t('ns:item')for a plural key withoutcountreturnsstring, ast('item')does, instead ofnever.