Guide

Locale detection

How the locale is chosen for a page or an API request: URL, query, state, cookie, Accept-Language and default, and when a visitor is redirected.

Every request gets one locale, picked from the same list of sources in the same order on the server and in the browser. Read this when you decide how first-time visitors land on a language, whether their choice is remembered, and what your server API sees. You get the precedence, the redirect each strategy makes, and the options that turn each source on or off.

Prerequisites — Routing strategies

The precedence

The first source that names an enabled locale wins. An invalid or malformed value (an unknown code, a broken cookie) is skipped and the next source counts.

#SourceCounts when
1URL prefix (/de/about)prefix strategies. Under prefix_except_default and prefix_and_default an unprefixed URL is the default locale
2?locale=depages: no_prefix only. Server API helpers: always, and before the URL
3Locale state set by the app (useI18nLocale().setLocale())the URL has no locale
4CookielocaleCookie is set ('user-locale' automatically under no_prefix)
5Accept-Language headerautoDetectLanguage is true (default)
6defaultLocalealways

So under a prefix strategy the URL decides what a page shows; the preference sources (3–5) only decide where a visitor is redirected, and only on the paths autoDetectPath allows. Under no_prefix there is nothing to redirect, and the preference sources decide what the page shows.

Decision flow: a locale prefix in the URL renders that locale. Without one, no_prefix renders from the preference sources, prefix_and_default renders the default locale, and prefix or prefix_except_default redirect by preference when autoDetectPath allows the path

Redirects per strategy

Redirects are a 302, made on the server before the page renders, and by a route middleware for client navigations. Query and hash are kept. With redirects: false none happen; wrong prefixes are still a 404.

Visitor prefers de (cookie or header), defaultLocale: 'en', default autoDetectPath: '/':

Requestprefix_except_defaultprefixprefix_and_defaultno_prefix
/→ /de→ /de/ in en/ in de
/about/about in en→ /de/about/about in en/about in de
/fr/about/fr/about/fr/about/fr/about—
/en/about404/en/about/en/about—

prefix_and_default never redirects: an unprefixed URL belongs to the default locale, and other locales live under their prefix. A visitor who prefers en is not redirected from / under prefix_except_default. Targets have no trailing slash (/de, not /de/) and follow custom paths (/about → /de/ueber-uns).

Wherever the cookie or the header decides (the redirect from /, every page under no_prefix), the response carries Vary: Cookie, Accept-Language, so a CDN keeps the answers apart.

autoDetectPath

Where the preference sources may move a visitor:

ValuePreference counts on
'/' (default)/ only. Deep links stay in the language of the URL
'no_prefix'every path without a locale prefix (/about → /de/about)
'/welcome'that exact path (a trailing slash is ignored)
'*'every path, prefixed ones too: /about and /fr/about → /de/about when the visitor prefers de
  • On paths it does not allow, an unprefixed URL is treated as the default locale.
  • prefix is the exception: it has no unprefixed pages, so /about always goes to the visitor's locale (/de/about with cookie de, /en/about with no preference).
  • With '*' the server ignores the app's state, and a prefixed URL no longer records its own locale in the cookie; the cookie changes when the visitor switches with $switchLocale.
With '*', shared links open in the reader's language, which can surprise. Prefer '/' or 'no_prefix' unless that is what you want.

Accept-Language

The header is read as RFC 9110 defines it and matched against the locale codes in your config:

  • Entries are tried by q value, highest first (fr;q=0.1, de;q=0.9 → de). q=0 means "not this language"; an entry with a malformed q is ignored. * matches nothing.
  • _ is read as - (en_US = en-US).
  • Per entry: the exact code, then shorter forms (de-AT → de), then a regional locale of the same language (en → en-US). Exact and shorter forms do not depend on the order of locales; among several regional matches, the first in locales wins.
  • Scripts are never crossed: zh-TW and zh-HK imply Traditional, zh-CN Simplified, so zh-CN never matches a zh-Hant locale.
  • Disabled locales never match.
// locales: [{ code: 'en' }, { code: 'de' }, { code: 'zh-Hant' }]
// 'de-CH,de;q=0.9,en;q=0.8'  → 'de'
// 'en-GB'                    → 'en'
// 'zh-TW'                    → 'zh-Hant'
// 'zh-CN'                    → no match, next source

Turn header detection off with autoDetectLanguage: false; the cookie and the state still count.

  • Off by default for the prefix strategies (localeCookie: null). Redirects then work from the header alone, and the choice is not remembered across visits. Set localeCookie: 'user-locale' to remember it.
  • Under no_prefix it is 'user-locale' unless you set another name. localeCookie: null turns it off there too.
  • It is written when the visitor switches ($switchLocale, <i18n-switcher>, useI18nLocale().setLocale()), and on a server render of a page under a prefix (/fr/about stores fr; not under autoDetectPath: '*' with redirects on, where the URL can still move). A client navigation to a page stores that page's locale too, also on an unprefixed page of the default locale; a first load of that URL does not. Under no_prefix and in hash mode a navigation stores only a change. A 404 stores nothing, unless $switchLocale lands on it.
  • It lasts one year (path=/, SameSite=Lax).
  • Under hashMode the preference lives in the hash-locale cookie instead; server and client read the same one. The server renders that locale and, outside prefix, never redirects; a locale in the hash (/#/de) wins over it and applies right after hydration. Under prefix there is no page on /, so / is rendered in the browser, which goes to /#/de by the cookie, then the browser language (autoDetectLanguage); any other document (/about, /de) redirects into the hash.

Read and set the preference with useI18nLocale, not with useCookie or useState directly.

Your own detection

To pick the locale yourself (a user setting, a subdomain), call useI18nLocale().setLocale(code) in a plugin that runs before the module's plugin. It becomes source 3, the app's state. Custom locale detection has the full recipe, including turning the built-in detection off.

Server API routes

useLocaleServerMiddleware and useTranslationServerMiddleware use their own order, because an API URL is rarely localized:

  1. ?locale=
  2. a [locale] route param, or a locale prefix in the path
  3. cookie (when localeCookie is set)
  4. Accept-Language (when autoDetectLanguage is on)
  5. defaultLocale

localeCookie: null and autoDetectLanguage: false switch sources 3 and 4 off here too.

Runtime overrides

Runtime overrides change the list the sources are matched against:

  • Runtime disabledLocales are removed from every step: a cookie or header naming one is skipped.
  • A runtime defaultLocale is step 6 under prefix and no_prefix.
  • Under prefix_except_default and prefix_and_default the default locale is fixed at build (its pages are the unprefixed routes). A runtime defaultLocale, or disabling the build default, is ignored with a warning.

Custom locale detection

Server routes and API

useI18nLocale