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 A request is checked against URL prefix, then cookie or override, then q-weighted Accept-Language, then geo-IP; each candidate is validated against the supported set via RFC 4647 lookup before being accepted, otherwise it falls through to the next signal and finally the default. Request in → highest-priority valid signal wins 1. URL prefix /de/pricing 2. Cookie / explicit override Cookie: locale=fr-FR 3. Accept-Language (q-weighted) fr-CA,fr;q=0.9,en;q=0.8 4. Geo-IP fallback last resort, weak signal RFC 4647 lookup validate tag → truncate fr-CA → fr → match? Resolved locale or supported default
Each signal is validated against the supported set before it can win; invalid candidates fall through to the next.

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:

  • 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.

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);

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.

Part of Core i18n Architecture & Locale Negotiation.