Locale Detection — Detecting User Language Preferences in the Browser
In this tutorial, you will learn about Locale Detection. We cover key concepts, practical examples, and best practices to help you master this topic.
Locale detection determines the user's language and region preferences from browser settings, URL parameters, cookies, and stored preferences.
What You'll Learn
By the end of this tutorial, you'll understand multiple strategies for detecting user locale, how to build a fallback chain, how to persist locale preferences, and how to avoid common detection pitfalls.
Why It Matters
Users should see your application in their preferred language without manual configuration. Good locale detection is invisible — the right language just appears. Poor detection shows the wrong language, forces users to find a switcher, and increases bounce rates for international audiences.
Real-World Use
A travel booking site detects user locale from the Accept-Language header on first visit, checks for a locale cookie on subsequent visits, and allows URL-based override (?lang=de-DE). A user in Switzerland with browser language set to fr-CH sees French content with Swiss date formats. The experience feels native.
Detection Strategy Priority
graph TD
A[Locale Detection] --> B{Check URL param?}
B -->|Yes| C[Use ?lang= value]
B -->|No| D{Check cookie?}
D -->|Yes| E[Use stored locale]
D -->|No| F{Check localStorage?}
F -->|Yes| G[Use saved preference]
F -->|No| H{Check navigator.language?}
H -->|Yes| I[Use browser language]
H -->|No| J[Use default locale]
C --> K[Apply detected locale]
E --> K
G --> K
I --> K
J --> K
style K fill:#27ae60,color:#fff
style J fill:#e74c3c,color:#fff
Complete Locale Detection
// i18n/locale-detector.js — Multi-strategy locale detection
class LocaleDetector {
constructor(options = {}) {
this.options = {
supportedLocales: ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'ar-SA'],
defaultLocale: 'en-US',
cookieName: 'locale',
storageKey: 'preferred_locale',
urlParamName: 'lang',
...options
};
}
// Run all detection strategies in order
detect() {
return this.fromUrl()
|| this.fromCookie()
|| this.fromStorage()
|| this.fromNavigator()
|| this.fromHtmlLang()
|| this.default();
}
// 1. URL parameter override (?lang=fr-FR)
fromUrl() {
const params = new URLSearchParams(window.location.search);
const lang = params.get(this.options.urlParamName);
if (lang) {
const matched = this.bestMatch(lang);
if (matched) {
console.log(`Locale detected from URL: ${matched}`);
return matched;
}
}
return null;
}
// 2. Cookie (persistent across sessions)
fromCookie() {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith(`${this.options.cookieName}=`));
if (cookie) {
const value = cookie.split('=')[1];
const matched = this.bestMatch(value);
if (matched) {
console.log(`Locale detected from cookie: ${matched}`);
return matched;
}
}
return null;
}
// 3. localStorage (persistent across sessions)
fromStorage() {
try {
const stored = localStorage.getItem(this.options.storageKey);
if (stored) {
const matched = this.bestMatch(stored);
if (matched) {
console.log(`Locale detected from storage: ${matched}`);
return matched;
}
}
} catch (e) {
// localStorage may be disabled in some browsers
}
return null;
}
// 4. Browser language setting (navigator.language / navigator.languages)
fromNavigator() {
// navigator.languages gives a prioritized list
const languages = navigator.languages || [navigator.language || navigator.userLanguage];
for (const lang of languages) {
const matched = this.bestMatch(lang);
if (matched) return matched;
}
return null;
}
// 5. HTML lang attribute (set by server)
fromHtmlLang() {
const htmlLang = document.documentElement.lang;
if (htmlLang) {
const matched = this.bestMatch(htmlLang);
if (matched) {
console.log(`Locale detected from HTML lang: ${matched}`);
return matched;
}
}
return null;
}
// Default fallback
default() {
console.log(`Using default locale: ${this.options.defaultLocale}`);
return this.options.defaultLocale;
}
// Find best match among supported locales
bestMatch(locale) {
// Normalize
const normalized = locale.replace(/_/g, '-');
// Exact match
if (this.options.supportedLocales.includes(normalized)) {
return normalized;
}
// Language + region match (different separator)
const withDash = normalized.replace(/[_-]/g, '-');
if (this.options.supportedLocales.includes(withDash)) {
return withDash;
}
// Language-only match
const lang = normalized.split('-')[0].toLowerCase();
const match = this.options.supportedLocales.find(supported => {
const supportedLang = supported.split('-')[0].toLowerCase();
return supportedLang === lang;
});
return match || null;
}
// Persist the user's choice
persist(locale) {
// Save to cookie (expires in 1 year)
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
document.cookie = `${this.options.cookieName}=${locale}; expires=${expires.toUTCString()}; path=/`;
// Save to localStorage
try {
localStorage.setItem(this.options.storageKey, locale);
} catch (e) {}
}
// Get all possible user preferences for debugging
debug() {
return {
url: this.fromUrl(),
cookie: this.fromCookie(),
storage: this.fromStorage(),
navigator: this.fromNavigator(),
html: this.fromHtmlLang(),
resolved: this.detect(),
navigatorLanguages: navigator.languages || [navigator.language],
supportedLocales: this.options.supportedLocales
};
}
}
// Usage
const detector = new LocaleDetector({
supportedLocales: ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP', 'ar-SA'],
defaultLocale: 'en-US'
});
const locale = detector.detect();
console.log('Using locale:', locale);
// User switches language
function switchLocale(newLocale) {
detector.persist(newLocale);
window.location.reload();
}
Server-Side Locale Detection
// middleware/locale-detection.js — Server-side locale detection (Node.js/Express)
function localeDetectionMiddleware(supportedLocales = ['en-US'], defaultLocale = 'en-US') {
return (req, res, next) => {
// Priority: 1. URL parameter, 2. Cookie, 3. Accept-Language header
// 1. URL parameter
const urlLang = req.query.lang || req.query.locale;
if (urlLang && supportedLocales.includes(urlLang)) {
req.locale = urlLang;
res.cookie('locale', urlLang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true });
return next();
}
// 2. Cookie
const cookieLang = req.cookies?.locale;
if (cookieLang && supportedLocales.includes(cookieLang)) {
req.locale = cookieLang;
return next();
}
// 3. Accept-Language header
const acceptLanguage = req.headers['accept-language'];
if (acceptLanguage) {
// Parse Accept-Language: "en-US,en;q=0.9,fr;q=0.8"
const parsed = acceptLanguage
.split(',')
.map(entry => {
const [locale, q] = entry.trim().split(';q=');
return { locale: locale.trim(), quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality);
for (const pref of parsed) {
// Try exact match
if (supportedLocales.includes(pref.locale)) {
req.locale = pref.locale;
return next();
}
// Try language-only match
const lang = pref.locale.split('-')[0];
const match = supportedLocales.find(s => s.startsWith(lang));
if (match) {
req.locale = match;
return next();
}
}
}
// Default
req.locale = defaultLocale;
next();
};
}
// Usage in Express app
// app.use(localeDetectionMiddleware(['en-US', 'es-ES', 'fr-FR'], 'en-US'));
Persistence and Switching
// i18n/locale-switcher.js — UI for locale switching
class LocaleSwitcher {
constructor(detector, onSwitch) {
this.detector = detector;
this.onSwitch = onSwitch;
this.currentLocale = detector.detect();
}
render(container) {
const supported = this.detector.options.supportedLocales;
const select = document.createElement('select');
select.setAttribute('aria-label', 'Select language');
select.style.cssText = 'padding: 8px; border-radius: 4px; font-size: 14px;';
supported.forEach(locale => {
const option = document.createElement('option');
option.value = locale;
option.textContent = this.getDisplayName(locale);
if (locale === this.currentLocale) {
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (e) => {
const newLocale = e.target.value;
this.detector.persist(newLocale);
this.onSwitch(newLocale);
});
container.appendChild(select);
}
getDisplayName(locale) {
// Use Intl.DisplayNames for native locale names
try {
const displayNames = new Intl.DisplayNames([locale], { type: 'language' });
return displayNames.of(locale.split('-')[0]);
} catch (e) {
// Fallback
const names = {
'en-US': 'English (US)',
'es-ES': 'Espanol',
'fr-FR': 'Francais',
'de-DE': 'Deutsch',
'ja-JP': 'Japanese',
'ar-SA': 'العربية'
};
return names[locale] || locale;
}
}
}
// Usage
// const switcher = new LocaleSwitcher(detector, (locale) => {
// window.location.href = `/?lang=${locale}`;
// });
// switcher.render(document.getElementById('locale-switcher'));
Common Mistakes
- Relying only on navigator.language. navigator.language returns the browser UI language, not necessarily the user's content language preference. Use navigator.languages for the full prioritized list.
- Not persisting user choice. If a user manually switches to German, your app should remember this choice. Without persistence, every new session or navigation resets to the detected locale.
- Ignoring Accept-Language on the server. Client-side detection runs after page load, causing a flash of content in the wrong language. Server-side detection from Accept-Language sends the correct locale on the first render.
- Overriding user choice with auto-detection. If a user explicitly chose French, don't override it with browser language detection on the next visit. User preference should always win.
- No URL-based override. Users should be able to share links with a specific language. Without URL-based locale (e.g., ?lang=es), shared links always use the sharer's language, not the content's language.
Practice Questions
- What order should locale detection strategies be applied?
- How does navigator.languages differ from navigator.language?
- Why should server-side locale detection use the Accept-Language header?
- How do you persist a user's locale preference across sessions?
- Why should user-explicit locale choices override auto-detection?
Challenge: Build a complete locale detection system that supports URL parameter override, cookie persistence, localStorage, navigator.languages, and a fallback default. Include a locale switcher dropdown that shows native language names (e.g., "Deutsch" for German, "Francais" for French) using Intl.DisplayNames.
FAQ
Mini Project
Build a locale detection demo page that shows: which detection strategy resolved (URL, cookie, storage, navigator, or default), the full debug output of all strategies, a locale switcher that persists choice, and a simulation mode where you can override navigator.languages with test values. Visualize the detection priority chain.
What's Next
You've mastered locale detection. Next, learn about the Accept-Language Header for server-side locale negotiation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro