Locale Negotiation Strategies

Locale negotiation is the deterministic resolution order — URL prefix, then cookie or explicit override, then a q-weighted Accept-Language parse, then geo-IP — that picks one supported locale per request before anything renders. When that order is undefined, you get the classic symptom: a user with ?lang=de in the URL still sees English because a stale cookie or a sloppy Accept-Language parse silently won the tie.

This area sits at the root of Core i18n Architecture & Locale Negotiation: the locale chosen here is the input to every downstream stage, so an ambiguous or non-deterministic resolver corrupts formatting, pluralization, and caching all at once. Get the order right, validate every tag against BCP 47, and the rest of the pipeline becomes predictable.

Locale negotiation resolution order Four signals are tried strictly in priority order. URL prefix, cookie or explicit override, and geo-IP are each accepted only on an exact membership match against the supported set. The multi-value, q-weighted Accept-Language tier is resolved with RFC 4647 lookup, which truncates fr-CA to fr until it hits a supported locale. The first tier that yields a valid, supported tag wins and becomes the resolved locale; if every signal is invalid or unsupported, the request falls through to the declared supported default. One request first valid, supported signal wins 1 · URL prefix /de/pricing 2 · Cookie / override locale=fr-FR 3 · Accept-Language fr-CA,fr;q=0.9,en;q=0.8 4 · Geo-IP fallback weak signal · last resort if unsupported if unsupported if unsupported ✓ exact match ✓ exact match RFC 4647 lookup fr-CA → fr ✓ exact match Resolved locale one supported BCP 47 tag Supported default DEFAULT_LOCALE = en-US no signal matched
Signals are tried in strict priority order and the first valid, supported tag wins. Tiers 1, 2, and 4 accept only an exact membership match; only the q-weighted Accept-Language tier uses RFC 4647 lookup truncation. If nothing matches, the declared default is returned.

Prerequisites

Concept & spec — what “negotiation” actually means

Locale negotiation is content negotiation specialized to language. The reader sends preferences (a URL, a cookie, an Accept-Language header), the server holds a fixed set of available representations (your supported locales), and the resolver computes the best available match. Three specs define the rules and you should treat them as load-bearing, not trivia:

Negotiation across browser, edge, application and CDN The browser sends a weighted Accept-Language header, the edge parses and canonicalizes it into a single tag, the application receives that tag as a header rather than re-parsing, the reader is redirected once to the prefixed URL, and the prefixed URL is then served from the CDN as its own cache entry that varies on Accept-Language. One request, one resolved locale Browser Edge App CDN GET /pricing Accept-Language: pt-BR,pt;q=0.9 parse, score, canonicalize X-Locale: pt-BR 302 to /pt-BR/pricing GET /pt-BR/pricing cached, Vary: Accept-Language
Negotiation happens exactly once; every later request is a cheap path match.
  • RFC 5646 (BCP 47) defines the syntax of a language tag: language-script-region-variant, e.g. zh-Hans-CN or sr-Latn. Every candidate you accept must be a well-formed tag.
  • RFC 4647 defines matching: basic filtering, extended filtering, and lookup. Lookup is the one you almost always want for UI — it returns a single best match by progressively truncating the requested tag (fr-CAfr → default) until it hits a supported locale.
  • ECMA-402 exposes these in the platform: Intl.Locale parses and canonicalizes tags, and Intl.PluralRules/Intl.DateTimeFormat consume the result downstream.

The critical property is determinism: given the same URL, cookie, and headers, the resolver must always return the same locale. That is why the priority order is fixed (URL prefix > cookie/override > Accept-Language > geo-IP) rather than “whichever signal we read first.” The resolved value then becomes the contract for the rest of Core i18n Architecture & Locale Negotiation — it feeds the fallback chain when a specific string is missing, and it parameterizes ICU Message Format and pluralization at render time.

A note on geo-IP: it is the weakest signal and belongs last, behind even an empty Accept-Language. IP geolocation tells you where a request egresses, not what language a human reads — it misfires for VPNs, expatriates, travellers, and multilingual regions. Use it only to seed a default and always let an explicit signal override it.

Step-by-step implementation

The resolver below is the heart of every framework integration. It encodes the priority order, validates each candidate against the supported set, and uses RFC 4647 lookup via @formatjs/intl-localematcher for the header path. Build it once, then wire it into middleware.

1. Define the supported set and a guarded validator

Keep the supported set as canonical BCP 47 tags and reject anything not in it. Canonicalize incoming candidates with Intl.Locale so EN-us and en_US-style noise normalize before comparison.

// locale-resolver.ts
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';

export const SUPPORTED_LOCALES = ['en-US', 'en-GB', 'fr-FR', 'de-DE', 'ja-JP'] as const;
export const DEFAULT_LOCALE = 'en-US';

// Canonicalize then check membership. Returns undefined for invalid/unsupported.
function asSupported(tag?: string | null): string | undefined {
  if (!tag) return undefined;
  try {
    const canonical = new Intl.Locale(tag.replace('_', '-')).toString();
    return SUPPORTED_LOCALES.find(
      (l) => l.toLowerCase() === canonical.toLowerCase(),
    );
  } catch {
    return undefined; // malformed tag, e.g. "en--US"
  }
}

2. Encode the priority order

Each tier is tried in turn; the first that yields a supported tag wins. Only the Accept-Language tier needs RFC 4647 lookup, because it is the only multi-value, q-weighted source.

export function resolveLocale(input: {
  pathLocale?: string | null;
  cookieLocale?: string | null;
  headers?: Record<string, string>;
  geoLocale?: string | null;
}): string {
  // 1. URL prefix — most explicit, shareable, cacheable
  const fromPath = asSupported(input.pathLocale);
  if (fromPath) return fromPath;

  // 2. Cookie / explicit override — a deliberate user choice
  const fromCookie = asSupported(input.cookieLocale);
  if (fromCookie) return fromCookie;

  // 3. Accept-Language — q-weighted parse + RFC 4647 lookup
  if (input.headers?.['accept-language']) {
    const requested = new Negotiator({ headers: input.headers }).languages();
    // match() returns DEFAULT_LOCALE if nothing matches, never throws
    const fromHeader = match([...requested], SUPPORTED_LOCALES, DEFAULT_LOCALE);
    if (input.headers['accept-language'].trim()) return fromHeader;
  }

  // 4. Geo-IP — weakest signal, last resort
  const fromGeo = asSupported(input.geoLocale);
  if (fromGeo) return fromGeo;

  return DEFAULT_LOCALE;
}

3. Wire it into request middleware

Resolve once at the edge of the request, attach the result to the request, and persist a deliberate choice as a cookie so the next request short-circuits to tier 2. Set Vary so a CDN never serves one locale’s HTML to another locale’s reader.

// Express middleware
import { resolveLocale } from './locale-resolver';

app.use((req, res, next) => {
  const resolved = resolveLocale({
    pathLocale: req.params.locale,
    cookieLocale: req.cookies?.locale,
    headers: req.headers as Record<string, string>,
    geoLocale: req.headers['x-geo-country-locale'] as string,
  });

  req.locale = resolved;
  res.cookie('locale', resolved, {
    httpOnly: false, // client may need to read it for UI state
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 365 * 24 * 60 * 60 * 1000,
  });
  res.set('Vary', 'Accept-Language, Cookie');
  next();
});

For a fuller, production-grade Node walkthrough — including override precedence and redirect handling — follow how to implement locale negotiation in Express.js.

Configuration reference

Option Type Description / default
SUPPORTED_LOCALES string[] (BCP 47) Canonical tags you actually ship. No default — explicit is mandatory.
DEFAULT_LOCALE string Returned when no signal matches. Must be a member of SUPPORTED_LOCALES.
matchingAlgorithm 'lookup' | 'best fit' RFC 4647 strategy for the header tier. lookup is deterministic; default lookup.
cookieName string Persistence key for an explicit choice. Default locale.
cookieMaxAge number (ms) Lifetime of the persisted choice. Default 31536000000 (1 year).
varyHeaders string Cache key inputs. Default Accept-Language, Cookie.
geoEnabled boolean Whether tier 4 (geo-IP) seeds a default. Default false.
trustPathPrefix boolean Treat a URL prefix as authoritative over cookie. Default true.
Trust and cacheability of the four locale signals The URL prefix is set by your own redirect, is cacheable as the URL itself, and is what the decision should ultimately rest on. A locale cookie is set by the reader and is cacheable only as a Vary key, so use it to remember an explicit choice. Accept-Language is set by the browser and makes a reasonable first guess. Geo-IP is set by the network, barely cacheable, and belongs only in a last-resort position. How far each signal can be trusted Set by Cacheable as Use it for URL prefix your redirect the URL itself the decision Locale cookie the reader a Vary key remembering a choice Accept-Language the browser a Vary key the first guess Geo-IP the network not really a last resort
Trust decreases down the table; so does cacheability. Both argue for the URL as the final answer.

Framework variants

The resolver is framework-agnostic; only the wiring differs. Reuse resolveLocale everywhere so the priority order stays identical across SSR, edge, and client.

Next.js (App Router middleware)

Run negotiation in middleware.ts at the edge, then redirect to the prefixed path so the URL itself becomes the authoritative signal on every subsequent request. The detailed config — matcher patterns, redirect vs. rewrite, and avoiding redirect loops — lives in Next.js App Router i18n middleware configuration.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { resolveLocale, SUPPORTED_LOCALES } from './locale-resolver';

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const hasPrefix = SUPPORTED_LOCALES.some(
    (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`,
  );
  if (hasPrefix) return NextResponse.next();

  const locale = resolveLocale({
    cookieLocale: req.cookies.get('locale')?.value,
    headers: Object.fromEntries(req.headers),
    geoLocale: req.geo?.country, // map country -> locale upstream
  });
  return NextResponse.redirect(new URL(`/${locale}${pathname}`, req.url));
}

export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] };

Express / Node.js backend

Use the middleware shown in step 3. The full pattern — splitting path parsing, override precedence, and per-route exemptions — is covered in how to implement locale negotiation in Express.js.

Vue / Nuxt

Resolve on the server (Nitro middleware) and hand the result to vue-i18n as locale. Keep client switching as a cookie write plus a navigation to the prefixed route so the URL stays authoritative; never let the client silently diverge from the server-resolved value. The Composition API wiring is in the Vue i18n Composition API guide.

// server/middleware/locale.ts (Nitro)
import { resolveLocale } from '~/locale-resolver';

export default defineEventHandler((event) => {
  const locale = resolveLocale({
    pathLocale: getRouterParam(event, 'locale'),
    cookieLocale: getCookie(event, 'locale'),
    headers: getRequestHeaders(event) as Record<string, string>,
  });
  event.context.locale = locale;
});

React (SSR-safe provider)

Pass the server-resolved locale as a prop and only reconcile with a client override after hydration to avoid a mismatch. Reading the cookie during the first client render is the most common source of hydration warnings.

import { createContext, useContext, useState, useEffect } from 'react';

const LocaleContext = createContext<string>('en-US');

export function LocaleProvider({
  initialLocale,
  children,
}: { initialLocale: string; children: React.ReactNode }) {
  const [locale, setLocale] = useState(initialLocale); // matches SSR output
  useEffect(() => {
    const stored = document.cookie.match(/locale=([^;]+)/)?.[1];
    if (stored && stored !== locale) setLocale(stored); // post-hydration only
  }, [locale]);
  return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}

export const useLocale = () => useContext(LocaleContext);

Angular

Angular’s built-in @angular/localize compiles one bundle per locale at build time, so negotiation happens at the routing edge rather than inside the app: an Express or CDN handler runs resolveLocale and redirects to the locale-specific deployment (/de/). Keep the same resolver so the priority order is identical to every other stack; the extraction and per-locale build details live in the Angular localization module setup guide.

// server.ts — Angular Universal / SSR entry
import { resolveLocale } from './locale-resolver';

server.get('*', (req, res, next) => {
  const locale = resolveLocale({
    pathLocale: req.path.split('/')[1],
    cookieLocale: req.cookies?.locale,
    headers: req.headers as Record<string, string>,
  });
  // each locale is a distinct compiled bundle; redirect once, then serve statically
  if (!req.path.startsWith(`/${locale}/`)) {
    return res.redirect(302, `/${locale}${req.path}`);
  }
  next();
});

Verification

Negotiation is pure and header-driven, so it is cheap to lock down with contract tests. Assert the priority order, the q-weighted parse, RFC 4647 truncation, and graceful handling of malformed input. Run this in CI before deploy.

// locale-resolver.test.ts (Vitest)
import { describe, it, expect } from 'vitest';
import { resolveLocale } from './locale-resolver';

const h = (al: string) => ({ 'accept-language': al });

describe('locale negotiation order', () => {
  it('URL prefix beats everything', () => {
    expect(
      resolveLocale({ pathLocale: 'de-DE', cookieLocale: 'fr-FR', headers: h('en-US') }),
    ).toBe('de-DE');
  });

  it('cookie beats Accept-Language', () => {
    expect(resolveLocale({ cookieLocale: 'fr-FR', headers: h('en-US') })).toBe('fr-FR');
  });

  it('q-weighting + RFC 4647 lookup: fr-CA truncates to fr-FR via best match', () => {
    expect(resolveLocale({ headers: h('fr-CA,fr;q=0.9,en;q=0.8') })).toBe('fr-FR');
  });

  it('malformed tags fall through to default', () => {
    expect(resolveLocale({ pathLocale: 'en--US', cookieLocale: 'zz' })).toBe('en-US');
  });

  it('unsupported language falls back to default', () => {
    expect(resolveLocale({ headers: h('zh-CN') })).toBe('en-US');
  });
});

Expected output: all five assertions pass. Wire it as a required check:

# .github/workflows/i18n.yml (excerpt)
- name: Locale negotiation contracts
  run: npx vitest run locale-resolver.test.ts --reporter=dot

Common pitfalls

  • No Vary header → a CDN caches /pricing in German and serves it to an English reader. Always set Vary: Accept-Language, Cookie (or, better, vary on the path prefix so the locale is in the cache key).
  • Reading the cookie during first client render → React/Next hydration mismatch. Reconcile overrides only after hydration; see hydration mismatch after locale switch in Next.js.
  • Treating Accept-Language as a single value → you ignore the q-weights entirely. Parse the whole list and apply RFC 4647 lookup. The full set of header edge cases — q=0, *, i-default, casing, whitespace — is catalogued in detecting locale from the Accept-Language header edge cases.
  • Geo-IP ahead of explicit signals → VPN and expat users get the wrong language. Geo-IP is tier 4, full stop.
  • No region-to-language fallbackfr-CA with only fr-FR shipped 404s instead of degrading to fr. Use lookup truncation and a proper fallback chain; the no-404 pattern is in region-to-language fallback without a 404.
  • Accepting unvalidated tags → a crafted Accept-Language or path segment reaches Intl.DateTimeFormat and throws. Always run candidates through asSupported first.

FAQ

What is the correct priority order for locale signals?

URL prefix first (it is explicit, shareable, and cacheable), then a cookie or explicit user override, then a q-weighted Accept-Language parse resolved with RFC 4647 lookup, and finally geo-IP as a last resort. The first tier that produces a supported, valid tag wins, which keeps the result deterministic.

What is the difference between RFC 4647 filtering and lookup?

Filtering returns all supported tags that match a requested range (useful for listing available options), while lookup returns the single best match by progressively truncating the requested tag (fr-CAfr → default) until it finds one. For choosing the one locale to render, you want lookup.

Why should geo-IP be the lowest-priority signal?

IP geolocation reflects network egress, not the language a person reads. It breaks for VPNs, travellers, expatriates, and multilingual regions. Use it only to seed a default when no explicit signal exists, and always let URL, cookie, or Accept-Language override it.

How do I stop a CDN from serving the wrong locale?

Put the locale in the cache key. The simplest robust approach is a URL path prefix so each locale has a distinct URL; otherwise send Vary: Accept-Language, Cookie so the edge keys on the negotiation inputs instead of serving one cached response to everyone.

Do I need to validate language tags before using them?

Yes. Pass every candidate — from the path, cookie, or header — through Intl.Locale and a membership check against your supported set. Unvalidated tags cause Intl constructors to throw and open you to cache-key and header-injection bugs.

Should negotiation run on every request, or only the first?

Only the first. Negotiation is the expensive, cache-hostile part: it reads headers, scores them and produces a decision. Once that decision is expressed as a URL prefix, every subsequent request is a plain path match that the CDN can serve without consulting any header at all. Re-negotiating on each request throws that away and reintroduces the Vary explosion the prefix was meant to remove.

What should happen when the reader explicitly picks a language?

Persist it and stop guessing. An explicit choice outranks every inferred signal, so write it to a cookie with a root path, redirect to the prefixed URL for that locale, and let the URL be the source of truth from then on. The one thing not to do is keep re-running header negotiation underneath the choice — that is how readers end up flipped back to a language they just rejected.

Part of Core i18n Architecture & Locale Negotiation.