How to Implement Locale Negotiation in Express.js
Express.js ships no locale resolver, so teams hand-roll Accept-Language parsers that mishandle q-weights, ignore cookie overrides, or run in the wrong middleware order and silently bind the fallback locale. This page walks through a deterministic Express negotiation pipeline: RFC 9110 quality-value parsing, choosing between the accepts and negotiator libraries, giving an explicit cookie override the right precedence, and ordering middleware so req.locale is set before any route or static handler reads it.
The hard part is not reading the header — it is precedence. A user who clicked a language switcher expects their stored choice to beat whatever Chrome sends in Accept-Language, and a /de/ URL prefix should beat both. Get the order wrong and you ship a site that ignores its own language switcher. The resolved value here is what every downstream stage consumes, so it feeds straight into the fallback chain resolver and the formatting layer.
Root cause analysis
Two specs govern the header. RFC 9110 §12.5.4 (which obsoleted RFC 7231) defines Accept-Language as a comma-separated list of language ranges, each with an optional q quality weight from 0 to 1 with up to three decimal places; absent q defaults to 1. RFC 4647 defines how a range like en matches a tag like en-US. The browser sends preferences already sorted by intent, e.g. fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, and a q=0 explicitly rejects a language. A naive header.split(',')[0] parser breaks on every one of these rules: it keeps the ;q=... suffix, ignores weights, and never sees q=0 as a rejection.
The second failure mode is precedence and ordering. The Accept-Language header reflects a browser default the user may never have set; an explicit cookie or URL prefix reflects a deliberate choice and must win. And because Express runs middleware top-to-bottom, a negotiator registered after express.static or after the router will never run for those requests — req.locale is read before it is written, so everything silently gets the fallback.
Choosing accepts vs negotiator
You rarely need to parse the header by hand. Two battle-tested libraries already implement RFC 9110 weighting:
| Library | API surface | Best for |
|---|---|---|
negotiator |
new Negotiator(req).languages(available) |
Low-level, returns the q-sorted intersection with your supported list |
accepts |
accepts(req).language(available) |
Higher-level wrapper over negotiator; the same parser Express’s own req.acceptsLanguages() uses |
Because accepts is already a transitive dependency of Express, req.acceptsLanguages('en', 'fr', 'de') works with zero installs and returns the best supported match (or false). Use that for the header tier and layer the cookie and URL tiers on top.
Minimal reproducible example
This naive middleware looks correct and passes a casual smoke test, but ignores q-weights and has no cookie override — so it returns fr even when the user picked English in your switcher:
// BROKEN: takes the first token, ignores q-weights and cookies
app.use((req, res, next) => {
const header = req.headers['accept-language'] || 'en';
req.locale = header.split(',')[0].split('-')[0]; // 'fr' from 'fr;q=0.2, en;q=0.9'
next();
});
Sent Accept-Language: fr;q=0.2, en;q=0.9, the user clearly prefers English (q=0.9 beats q=0.2), but this code returns fr because it never reads the weights. Add a lang=en cookie from your language switcher and it is still ignored entirely.
The fix — annotated middleware
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
const SUPPORTED = ['en', 'fr', 'de', 'es', 'ja'];
const FALLBACK = 'en';
// 1. cookie-parser MUST be registered before the negotiator,
// otherwise req.cookies is undefined when we read it.
app.use(cookieParser());
app.use((req, res, next) => {
// Tier 1: explicit URL prefix beats everything (a deliberate, shareable choice).
const prefix = req.path.match(/^\/([a-z]{2})(?:\/|$)/);
if (prefix && SUPPORTED.includes(prefix[1])) {
req.locale = prefix[1];
return next();
}
// Tier 2: cookie set by the language switcher — a deliberate user override
// that must beat the browser's Accept-Language default.
const cookieLang = req.cookies.lang;
if (cookieLang && SUPPORTED.includes(cookieLang)) {
req.locale = cookieLang;
return next();
}
// Tier 3: q-weighted Accept-Language. req.acceptsLanguages uses the
// `accepts`/`negotiator` parser, so q-values and RFC 4647
// range matching are handled for us. Returns false if none match.
const negotiated = req.acceptsLanguages(...SUPPORTED);
req.locale = negotiated || FALLBACK; // Tier 4: deterministic fallback
next();
});
// Routes and static handlers come AFTER, so they can read req.locale safely.
app.get('/:lang?/products', (req, res) => {
res.json({ locale: req.locale });
});
Two non-obvious lines carry the fix. req.acceptsLanguages(...SUPPORTED) constrains the negotiation to your supported set and applies q-weighting in one call, so an unsupported top choice like pt-BR is skipped to the next acceptable entry rather than blindly assigned. The early return next() on each tier enforces precedence — once a higher-priority signal matches, lower tiers never run. When you persist the cookie back, set sameSite: 'lax', httpOnly: false only if a client script needs to read it, and secure: true in production.
Verification snippet
Drive each tier with curl and a quick assertion. The header tier is the easiest to get wrong, so test the q-weight ordering explicitly:
# Tier 3: q=0.9 English must beat q=0.2 French
curl -s -H 'Accept-Language: fr;q=0.2, en;q=0.9' localhost:3000/products
# => {"locale":"en"}
# Tier 2: cookie override beats the header default
curl -s -H 'Accept-Language: fr' -H 'Cookie: lang=de' localhost:3000/products
# => {"locale":"de"}
# Tier 4: unsupported header falls back deterministically
curl -s -H 'Accept-Language: pt-BR' localhost:3000/products
# => {"locale":"en"}
As a regression guard, assert the same three cases in a Supertest spec so a future middleware re-order cannot silently break precedence:
const request = require('supertest');
it('cookie override beats Accept-Language', async () => {
const res = await request(app)
.get('/products')
.set('Accept-Language', 'fr')
.set('Cookie', 'lang=de');
expect(res.body.locale).toBe('de');
});
When to escalate
This middleware covers the single-server case. If you terminate TLS or serve responses from a CDN, the q-weighted result must be reflected in a Vary: Accept-Language response header, or the edge cache will serve one user’s locale to everyone — a problem better solved by the broader Locale Negotiation Strategies patterns. If you need per-region defaults (e.g. en-GB vs en-US) or region-to-language collapse, the precedence tiers stay the same but the matching logic belongs in the fallback chain layer. And once a locale is resolved, hand it to your ICU message format layer so plurals and dates render correctly.
FAQ
Why does my language switcher get ignored even though the cookie is set?
Almost always a middleware-ordering bug. If cookie-parser or your negotiator is registered after express.static or after the router, req.cookies is undefined or req.locale is read before it is written, so the header tier wins by default. Register cookie-parser() then the negotiator at the top of the stack, before any route or static handler.
Do I need accept-language-parser, accepts, or negotiator?
accepts and negotiator both ship RFC 9110 q-weighting and are already transitive dependencies of Express — req.acceptsLanguages(...) uses them with zero installs. Reach for a separate library only if you need to inspect the full parsed list with raw q-values; for picking the best supported locale, the built-in method is enough.
How are q-weights actually compared?
Each language range carries a quality value from 0 to 1 (default 1 when omitted); the parser sorts ranges by descending q and returns the highest-weighted entry that intersects your supported set. A q=0 is an explicit rejection, so de;q=0 removes German from consideration entirely rather than ranking it last.
Related
- Locale Negotiation Strategies — the parent overview of resolution order across server, edge, and client.
- Setting up graceful fallback chains for missing strings — where region-to-language collapse and missing-key handling belong.
- Fallback Chain Configuration — designing the deterministic fallback the negotiator hands off to.
- ICU Message Format Deep Dive — rendering plurals and dates once the locale is resolved.
- Next.js App Router i18n middleware configuration — the same precedence problem in a framework router.
Part of Locale Negotiation Strategies.