Accept-Language Header — Using the Accept-Language HTTP Header for i18n
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
- 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.
- 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.
- 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.
- 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.
- 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
- How does the Accept-Language header communicate language preferences?
- What does the quality value (q=0.8) mean in Accept-Language?
- Why is the Vary: Accept-Language header important for CDN Caching?
- How should server-side locale negotiation handle unsupported locales?
- 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
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