Skip to content

Accept-Language Header — Using the Accept-Language HTTP Header for i18n

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Accept. We cover key concepts, practical examples, and best practices to help you master this topic.

The Accept-Language HTTP header communicates the user's language preferences to the server, enabling server-driven locale negotiation for multilingual sites.

What You'll Learn

By the end of this tutorial, you'll understand how the Accept-Language header works, how to parse quality values, how to implement server-side locale negotiation, and how to set the Content-Language response header.

Why It Matters

Client-side locale detection requires JavaScript and causes a flash of content in the wrong language. Server-side Accept-Language detection delivers the correct locale on the very first HTML response. This improves perceived performance, SEO (correct hreflang tags early), and accessibility (screen readers get the right language from the start).

Real-World Use

A global e-commerce platform uses Accept-Language to serve locale-specific HTML. A user in Belgium with browser set to "nl-BE, nl;q=0.9, fr;q=0.8, en;q=0.7" gets Dutch content with Belgian date/number formats. The server renders the correct prices (EUR), addresses, and legal text before any JavaScript runs.

Header Flow

sequenceDiagram
    participant B as Browser
    participant S as Server
    participant I as i18n Engine
    
    B->>S: GET /products
Accept-Language: fr-CH, fr;q=0.9, en;q=0.8 S->>I: Parse Accept-Language I->>I: Match against supported locales I-->>S: Best match: fr-CH S->>S: Render template with locale=fr-CH S-->>B: 200 OK
Content-Language: fr-CH
B->>B: Display French content with Swiss formats

Parsing Accept-Language

// i18n/accept-language.js — Parse Accept-Language header
class AcceptLanguageParser {
    // Parse the Accept-Language header value
    static parse(header) {
        if (!header) return [];

        return header
            .split(',')
            .map(entry => {
                const [locale, qValue] = entry.trim().split(';q=');
                return {
                    locale: locale.trim(),
                    quality: qValue ? parseFloat(qValue) : 1.0
                };
            })
            .filter(entry => entry.quality > 0)
            .sort((a, b) => b.quality - a.quality);
    }

    // Find best match among supported locales
    static negotiate(header, supportedLocales) {
        const preferences = this.parse(header);

        for (const pref of preferences) {
            const match = this.findMatch(pref.locale, supportedLocales);
            if (match) return match;
        }

        return supportedLocales[0] || 'en-US';
    }

    // Match a locale against supported list with fallbacks
    static findMatch(locale, supported) {
        // Normalize: replace underscores with hyphens
        const normalized = locale.replace(/_/g, '-');

        // Exact match
        if (supported.includes(normalized)) return normalized;

        // Language + region (with different separator)
        const variants = [
            normalized,
            normalized.toLowerCase(),
            normalized.toUpperCase()
        ];

        for (const variant of variants) {
            if (supported.includes(variant)) return variant;
        }

        // Language-only match
        const lang = normalized.split('-')[0].toLowerCase();
        const langMatch = supported.find(s => {
            const supportedLang = s.toLowerCase().split('-')[0];
            return supportedLang === lang;
        });

        if (langMatch) return langMatch;

        // Try parent locale (e.g., es-MX -> es-419 -> es)
        if (normalized.includes('-')) {
            const parent = normalized.split('-')[0];
            return supported.find(s => {
                const supportedLang = s.toLowerCase().split('-')[0];
                return supportedLang === parent;
            }) || null;
        }

        return null;
    }

    // Get all matched locales with quality scores
    static matchAll(header, supported) {
        const preferences = this.parse(header);
        const results = [];

        for (const pref of preferences) {
            const match = this.findMatch(pref.locale, supported);
            if (match) {
                results.push({
                    requested: pref.locale,
                    matched: match,
                    quality: pref.quality
                });
            }
        }

        return results;
    }
}

// Usage
const header = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.5';
const supported = ['en-US', 'fr-FR', 'de-DE', 'fr-CH'];

console.log('Parsed:', AcceptLanguageParser.parse(header));
console.log('Negotiated:', AcceptLanguageParser.negotiate(header, supported));
console.log('All matches:', AcceptLanguageParser.matchAll(header, supported));

Server-Side Integration

// middleware/i18n-negotiation.js — Express middleware for locale negotiation
class I18nNegotiationMiddleware {
    constructor(options = {}) {
        this.options = {
            supportedLocales: options.supportedLocales || ['en-US'],
            defaultLocale: options.defaultLocale || 'en-US',
            cookieName: options.cookieName || 'locale',
            ...options
        };
    }

    middleware() {
        return (req, res, next) => {
            // 1. Check cookie (explicit user choice always wins)
            const cookieLocale = req.cookies?.[this.options.cookieName];
            if (cookieLocale && this.options.supportedLocales.includes(cookieLocale)) {
                req.locale = cookieLocale;
                this.setResponseHeaders(res, cookieLocale);
                return next();
            }

            // 2. Negotiate from Accept-Language
            const acceptLanguage = req.headers['accept-language'];
            const negotiated = AcceptLanguageParser.negotiate(
                acceptLanguage,
                this.options.supportedLocales
            );

            req.locale = negotiated;
            this.setResponseHeaders(res, negotiated);
            next();
        };
    }

    setResponseHeaders(res, locale) {
        res.setHeader('Content-Language', locale);

        // Vary header tells caches that content varies by language
        res.setHeader('Vary', 'Accept-Language');
    }

    // Helper to generate locale-specific URLs
    static localeUrl(req, targetLocale) {
        const url = new URL(req.url, `${req.protocol}://${req.headers.host}`);
        url.searchParams.set('lang', targetLocale);
        return url.toString();
    }
}

Content-Language Response Header

// Set Content-Language in responses
// This tells browsers and search engines what language the content is in

// Example: Express route
app.get('/products', (req, res) => {
    const locale = req.locale; // Set by middleware
    const products = getProducts(locale);

    res.setHeader('Content-Language', locale);
    res.setHeader('Vary', 'Accept-Language, Cookie');

    res.render('products', {
        locale,
        products,
        // hreflang alternatives
        alternates: [
            { locale: 'en-US', url: '/en/products' },
            { locale: 'es-ES', url: '/es/products' },
            { locale: 'fr-FR', url: '/fr/products' }
        ]
    });
});

// The Vary: Accept-Language header is critical for CDN caching
// Without it, a CDN might serve English content to French users

Handling Quality Values

// Advanced: Quality-weighted locale negotiation
class QualityNegotiator {
    constructor(strategy = 'highest') {
        this.strategy = strategy; // 'highest', 'average', 'threshold'
    }

    negotiate(header, supportedLocales, options = {}) {
        const preferences = AcceptLanguageParser.parse(header);

        // Score each supported locale
        const scored = supportedLocales.map(locale => {
            let score = 0;
            let matches = [];

            preferences.forEach(pref => {
                const match = this.matchScore(pref.locale, locale);
                if (match > 0) {
                    score += match * pref.quality;
                    matches.push({ locale: pref.locale, score: match });
                }
            });

            return { locale, score, matches };
        });

        // Sort by score descending
        scored.sort((a, b) => b.score - a.score);

        // Apply minimum threshold
        if (options.minScore && scored[0]?.score < options.minScore) {
            return options.fallback || supportedLocales[0];
        }

        return scored[0]?.locale || supportedLocales[0];
    }

    matchScore(requested, supported) {
        const req = requested.replace(/_/g, '-').toLowerCase();
        const sup = supported.toLowerCase();

        // Exact match = highest score
        if (req === sup) return 1.0;

        // Same language + region (case difference)
        if (req === sup) return 0.95;

        // Same language
        const reqLang = req.split('-')[0];
        const supLang = sup.split('-')[0];
        if (reqLang === supLang) return 0.7;

        // Related language family
        const families = {
            'en': ['en', 'de'],  // Germanic
            'de': ['en', 'de'],
            'es': ['es', 'fr', 'it', 'pt'],  // Romance
            'fr': ['es', 'fr', 'it', 'pt'],
            'it': ['es', 'fr', 'it', 'pt'],
            'pt': ['es', 'fr', 'it', 'pt'],
        };

        if (families[reqLang]?.includes(supLang)) return 0.3;

        return 0;
    }
}

const negotiator = new QualityNegotiator();
const header = 'en-GB;q=0.9, fr-FR;q=0.8';
const supported = ['en-US', 'en-GB', 'fr-FR', 'de-DE'];
console.log('Quality negotiated:', negotiator.negotiate(header, supported));

Common Mistakes

  1. Not setting Vary: Accept-Language. Without Vary, CDNs and browsers cache responses without considering language. A French user might receive cached English content. Always set Vary: Accept-Language on multilingual pages.
  2. Ignoring quality values. Accept-Language includes quality values (q=0.8) that indicate preference weight. Sorting by quality ensures the user's top choice is selected.
  3. Overriding explicit user choice with Accept-Language. If a user has a locale cookie, respect it. Accept-Language should only be used when there's no stored preference.
  4. Not normalizing locale codes. Accept-Language may send "en-US", "en-us", "EN-US", or "en_US". Normalize all codes to a consistent format before matching.
  5. Not considering regional variants. Accept-Language: "es-MX" should match "es-419" (Latin American Spanish) if Mexican Spanish isn't available. Simple language-only matching loses this nuance.

Practice Questions

  1. How does the Accept-Language header communicate language preferences?
  2. What does the quality value (q=0.8) mean in Accept-Language?
  3. Why is the Vary: Accept-Language header important for CDN Caching?
  4. How should server-side locale negotiation handle unsupported locales?
  5. What is the difference between Content-Language and the attribute?

Challenge: Build a complete server-side locale negotiation system that parses Accept-Language with quality values, matches against a list of 10+ supported locales (including regional variants like es-MX, es-ES, pt-BR, pt-PT), implements quality-weighted scoring, sets proper Content-Language and Vary headers, and provides debug output showing the negotiation decision.

FAQ

What does the Accept-Language header look like?

Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.5. The browser sends a comma-separated list of locales with optional quality values (q=). Higher q = stronger preference.

Can Accept-Language be overridden in the browser?

Yes. Chrome and Firefox allow users to set custom language preferences in browser settings. Developers can override it in DevTools for testing.

Should I use Accept-Language or URL-based locale (example.com/fr/)?

Both. Accept-Language determines the initial locale on first visit. URL-based locale (subdomain or path) provides permanent, shareable locale-specific URLs. Accept-Language is the detection mechanism; URL structure is the delivery mechanism.

Does Accept-Language work for API responses?

Absolutely. Internationalized APIs should read Accept-Language to return localized error messages, date formats, and content. This is especially important for mobile apps that rely on API localization.

How does Accept-Language affect SEO?

Google uses Content-Language and html lang attributes (set from Accept-Language) to understand which language a page targets. Correct language signals improve search rankings in the target locale.

Mini Project

Build an Accept-Language debugger: a web page that displays the incoming Accept-Language header, parses it with quality values, shows the negotiation result against a configurable list of supported locales, visualizes the fallback chain, and lets you test different Accept-Language values to see how negotiation changes.

What's Next

You've mastered the Accept-Language header. Next, learn about i18next, the most popular JavaScript Internationalization library.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro