Skip to content

Locale Detection — Detecting User Language Preferences in the Browser

DodaTech Updated 2026-06-28 7 min read

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. What order should locale detection strategies be applied?
  2. How does navigator.languages differ from navigator.language?
  3. Why should server-side locale detection use the Accept-Language header?
  4. How do you persist a user's locale preference across sessions?
  5. 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

What is navigator.languages?

navigator.languages returns an array of the user's preferred languages in order of preference (e.g., ['fr-FR', 'en-US', 'de-DE']). It's more reliable than navigator.language, which only returns the primary language.

Should I detect locale on the client or server?

Both. Server-side detection from Accept-Language sets the correct locale on first render (no flash). Client-side detection handles dynamic changes and persistence. Use server as primary, client as enhancement.

How do I handle users who change their browser language?

Check navigator.languages on page load. If it differs from the persisted preference and the user hasn't manually switched, offer a suggestion banner: 'We detected your browser is set to French. Switch to French?'

What if the detected locale isn't supported?

Always have a fallback chain. If 'es-MX' isn't supported, try 'es', then the default locale. Inform the user that their preferred language isn't available and offer the locale switcher.

How do I test locale detection locally?

In Chrome DevTools > Settings > Languages, add multiple languages and reorder them. Set 'navigator.language' overrides in DevTools > Sensors > Location. Test each detection strategy by clearing cookies/storage.

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