I18n questions · Software localization
In the browser, navigator.languages is the user's ordered preference list as BCP 47 tags, most preferred first, and navigator.language is its first entry. On the server the same list arrives in the Accept-Language request header with q-values, for example fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5; the default weight is 1 and * matches anything else.
navigator.languages; // ["en-US", "zh-CN", "ja-JP"]
navigator.language; // "en-US"
Do not use Intl.DateTimeFormat().resolvedOptions().locale for this. It returns the locale the formatter actually uses, which with no arguments is the runtime's default locale (for example nl-BE), so it reflects system formatting settings rather than the language the user wants to read. And do not guess from IP geolocation: someone travelling through Spain still wants the language configured in their browser, and Google explicitly advises against redirecting users based on what you think their language is.
The preference list is only useful once matched against the locales you ship. Try each preferred tag exactly, then with likely subtags filled in (so zh-TW matches zh-Hant), then the language alone (so de-AT matches de):
const supported = ["en", "de", "pt-BR", "zh-Hans", "zh-Hant"];
function pickLocale(preferred = navigator.languages, fallback = "en") {
const lookup = new Map(supported.map((tag) => [tag.toLowerCase(), tag]));
for (const tag of preferred) {
const { language, script, region } = new Intl.Locale(tag).maximize();
const candidates = [
tag,
`${language}-${script}-${region}`,
`${language}-${region}`,
`${language}-${script}`,
language,
];
for (const candidate of candidates) {
const match = lookup.get(candidate.toLowerCase());
if (match) return match;
}
}
return fallback;
}
Intl.Locale has been available in all major browsers since 2020. Compare case-insensitively, because old Safari versions reported en-us, and keep the loop order: a user who lists ["de-CH", "en"] should get de, not en. On the server, parse Accept-Language into tags sorted by weight and feed them to the same function, guarding against malformed tags, which make Intl.Locale throw.
Treat detection as a default, not a decision. Show a language switcher, store the choice (cookie, local storage or the user profile) and read it before the browser list on later visits; the languagechange event tells you when the browser settings change. Where to persist the choice is covered in Where to save preferred language in React app?.
Published Oct 9, 2026 · Markdown version