Skip to content
Comvi

Locale detector

View .md

With an i18n instance that has two or more locales, as on the Quick start, read the user’s locale from the browser on startup and persist the choice with @comvi/plugin-locale-detector. Every source falls through instead of throwing when window or document is missing, so the same code runs on the server and does nothing there.

Terminal window
npm install @comvi/plugin-locale-detector
src/i18n.ts
import { createI18n } from '@comvi/core';
import { localeDetector } from '@comvi/plugin-locale-detector';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en' }).use(
localeDetector({ supportedLocales: ['en', 'uk', 'de'] }),
);
await i18n.init();

.use(localeDetector(…)) composes the plugin capability and registers the detector. Every option has a default. The installer receives the i18n instance and returns it widened with the plugin members; the plugin it queues runs inside init(), and the detector it registers is consulted after every plugin has run, so a loader composed after it is already in place. The detector’s result is applied through setLocale(), which loads the new locale’s namespaces before the switch takes effect. A detected locale equal to the current one switches nothing. The locale detected during init() is written to every configured cache once init() has succeeded, never before. Every later setLocale(), including the one your switcher makes, is written at once.

Always list the locales you ship in supportedLocales. Without it, a visitor whose browser language you do not ship, ja-JP on an en and uk app, gets ja as the active locale. With a loader composed, init() then rejects because no ja catalog loads, and it rejects again on every later visit, since the browser keeps reporting ja; nothing is cached from a failed init(), and a cached value whose locale fails to load is removed from the caches that hold it. A development build warns when the plugin starts with neither supportedLocales nor convertDetectedLocale.

src/detector-defaults.ts
import { localeDetectorPlugin } from '@comvi/plugin-locale-detector';
export const detector = localeDetectorPlugin({
supportedLocales: ['en', 'uk', 'de'],
caches: ['localStorage'],
cacheFirst: true,
order: ['querystring', 'localStorage', 'sessionStorage', 'cookie', 'navigator'],
});

Detection has two phases. With cacheFirst: true, the first entry in caches is read first and a valid tag there ends detection; the other cache entries are write-only. When it is empty, each source in order is tried left to right and the first valid tag wins. Set cacheFirst: false to let order alone decide priority, for example so a query parameter can override a persisted choice; caches are still written. A tag is used only if it is at most 35 characters and matches ^[a-z]{2,8}([_-][a-z\d]{1,8})*$ case-insensitively, so a hand-edited cookie cannot push an arbitrary string into locale.

With supportedLocales, a detected tag is matched with BCP 47 lookup: case-insensitive exact match, then shorter prefixes (zh-Hant-TW, zh-Hant, zh), then a regional variant of the base language (de to de-DE). A tag that matches nothing is skipped and the scan continues. Without supportedLocales or convertDetectedLocale, every detected and cached tag is shortened to its base language; every visit resolves pt-BR to pt. When nothing matches, the locale is fallbackLocale, and that result is not cached, so an unsupported browser language never overwrites a real choice.

Option Default Behaviour
order ['querystring', 'localStorage', 'sessionStorage', 'cookie', 'navigator'] Sources, tried left to right.
caches ['localStorage'] Where a detected or selected locale is written; the first is read on startup.
cacheFirst true Read the first cache before scanning order.
lookupQuerystring 'lng' Query parameter name.
lookupCookie 'i18n_lang' Cookie name.
lookupLocalStorage 'i18n_locale' localStorage key.
lookupSessionStorage 'i18n_locale' sessionStorage key.
cookieOptions { path: '/', sameSite: 'lax' } Also domain and secure. sameSite: 'none' adds Secure automatically.
cookieMaxAge 31536000 Cookie lifetime in seconds, one year.
fallbackLocale i18n.locale at plugin init Used when no source yields a supported tag.
supportedLocales none Restricts detected tags with BCP 47 lookup.
convertDetectedLocale none (tag) => string mapping applied to every raw tag.

Storage access is wrapped, so private mode, blocked cookies and quota errors are ignored and detection moves to the next source. Only one detector is active on an instance; a later registration replaces it. The plugin returns a cleanup function, so destroy() unsubscribes its localeChanged listener.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { localeDetector } from '@comvi/plugin-locale-detector';
export const i18n = createI18n({ locale: 'en' }).use(
localeDetector({
supportedLocales: ['en', 'uk', 'de'],
order: ['querystring', 'localStorage', 'navigator'],
caches: ['localStorage'],
cacheFirst: false,
lookupQuerystring: 'language',
}),
);
await i18n.init();

With ?language=de the query value wins and is persisted to localStorage; without it, detection continues to localStorage and then navigator.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { localeDetector } from '@comvi/plugin-locale-detector';
export const i18n = createI18n({ locale: 'en' }).use(
localeDetector({
supportedLocales: ['en', 'uk', 'de'],
order: ['cookie', 'navigator'],
caches: ['cookie'],
lookupCookie: 'comvi_locale',
cookieOptions: { sameSite: 'lax' },
}),
);
await i18n.init();

Persist to a cookie when your server or middleware also needs to read the selected locale. On the server every source returns nothing and the detector yields fallbackLocale, so make the server-side decision in your framework’s middleware and share it through the cookie; @comvi/next and @comvi/nuxt ship their own. Compose the detector on the client only when the instance lives at module scope on the server: a detector there runs once, at the first init(), and never again for a later request. See Render on the server.