# Locale detector

Detect the user's locale from the query string, storage, cookies or navigator.language, persist the choice, and restrict it to the locales you ship.

Canonical: https://comvi.io/docs/i18n/plugins/locale-detector

With an i18n instance that has two or more locales, as on the [Quick start](/docs/i18n/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.

## Install
**npm**

```bash
npm install @comvi/plugin-locale-detector
```

**pnpm**

```bash
pnpm add @comvi/plugin-locale-detector
```

**yarn**

```bash
yarn add @comvi/plugin-locale-detector
```

**bun**

```bash
bun add @comvi/plugin-locale-detector
```

## Detect and persist

`src/i18n.ts`

```ts

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`.

## Detection order

`src/detector-defaults.ts`

```ts

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.

## Options

| 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.

> **convertDetectedLocale switches supportedLocales off**
>
> When `convertDetectedLocale` is set, `supportedLocales` is never consulted: whatever the
> function returns is the locale, with no validation and no "matched nothing, keep scanning".
> Use it only for a mapping lookup cannot express, such as `(tag) => tag.replace('_', '-')`
> for a legacy cookie, and validate inside it. To restrict what is accepted, use
> `supportedLocales` alone.

## Common setups

`src/i18n.ts`

```ts

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`

```ts

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](/docs/i18n/ssr#locale-on-the-server).

## Read next

- [Switch the locale](/docs/i18n/language-switching): `setLocale`, persistence and direction.
- [Render on the server](/docs/i18n/ssr): detection on the server, cookie names per framework.
- [Overview](/docs/i18n/plugins): installers, plugin factories and registration options.
