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.

Express locale negotiation order Cookie-parser runs before the negotiator middleware, which resolves precedence URL prefix over cookie over Accept-Language over fallback, then binds req.locale before route handlers run. Middleware order (top → bottom) cookie-parser() negotiator middleware req.locale bound routes / static Precedence inside the negotiator URL prefix /de/page cookie override lang=fr Accept-Language q-weighted fallback locale en first match wins, stop descending only reached when no higher signal matches
Cookie-parser must run before the negotiator; inside it, the first matching signal wins.

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.

Part of Locale Negotiation Strategies.