Locale Detection and Routing at the Edge

This guide is part of Geolocation and Personalization at the Edge. It covers the language half of the problem: reading what the visitor’s browser actually asked for, matching it against what you actually ship, and turning that into a route.

The problem

Accept-Language looks like a simple header until you read a real one:

Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5

Five entries, four quality values, a regional variant you do not support, and a wildcard. A naive header.split(',')[0] gives you fr-CH, which is not in your locale list, so you fall through to English — even though this visitor explicitly ranked French above English. Meanwhile a visitor sending en-US gets matched to your en-GB bundle only if your matching does language-level fallback, and zh-Hant must not fall back to zh-Hans because they are not mutually readable.

Getting this right is a parsing problem plus a matching problem, and both have to run in a couple of hundred microseconds inside the isolate.

Root cause: quality-ordered ranges, not a single value

The header is a list of language ranges with optional quality weights, defined by RFC 9110. Entries are not in preference order in the string — they are ordered by their q value, which defaults to 1. A missing q means “most preferred”. A q of 0 means “explicitly refused”, which almost nobody handles and which matters when a visitor has actively excluded a language.

Matching is then a lookup problem: for each range in preference order, find the best available locale. “Best” means an exact tag match first, then a match on the primary language subtag, and never a silent cross-script substitution.

The reason this belongs in edge middleware rather than the origin is cost. Language negotiation determines which bundle, which route and which cached object serves the request. Deciding at the origin means the edge cannot serve the right cached variant, and you have given up the benefit.

Quality-ordered locale matching The header is split into ranges, sorted by descending quality value, and each range is tried against the supported locales — first for an exact tag match, then for a primary language subtag match — before falling back to the default. fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 sorted by q, descending fr-CH q=1.0 fr q=0.9 en q=0.8 de q=0.7 exact? no subtag fr → fr ✓ locale = fr route /fr/… q=0 means explicitly refused — never select that language, even as a fallback.
The first entry loses because you do not ship fr-CH. The second wins on a primary-subtag match, which is why matching must not stop at the first range.

Step 1 — Parse the header into ordered ranges

export interface LanguageRange {
  tag: string;      // lowercased, e.g. 'fr-ch' or '*'
  q: number;        // 0 … 1
}

export function parseAcceptLanguage(header: string | null): LanguageRange[] {
  if (!header) return [];

  const ranges: LanguageRange[] = [];
  for (const part of header.split(',')) {
    const [rawTag, ...params] = part.trim().split(';');
    const tag = rawTag.trim().toLowerCase();
    if (!tag) continue;

    let q = 1;
    for (const param of params) {
      const [k, v] = param.split('=').map((s) => s.trim());
      if (k === 'q') {
        const parsed = Number(v);
        // Malformed q is treated as absent (q=1), per the tolerant reading.
        if (!Number.isNaN(parsed)) q = Math.min(Math.max(parsed, 0), 1);
      }
    }
    ranges.push({ tag, q });
  }

  // Stable sort by descending q; equal q keeps document order, which is the
  // browser's own preference ordering.
  return ranges
    .map((r, i) => ({ r, i }))
    .sort((a, b) => b.r.q - a.r.q || a.i - b.i)
    .map(({ r }) => r);
}

Clamping q into [0, 1] and tolerating a malformed value keeps a hand-crafted header from throwing. Preserving document order for equal weights matters more than it looks: browsers commonly send several q=1 entries and expect the first to win.

Step 2 — Match ranges against your supported locales

const SUPPORTED = ['en', 'en-GB', 'de', 'fr', 'pt-BR', 'ja', 'zh-Hans', 'zh-Hant'] as const;
export type Locale = (typeof SUPPORTED)[number];
const DEFAULT_LOCALE: Locale = 'en';

const LOWER = SUPPORTED.map((l) => [l.toLowerCase(), l] as const);

export function matchLocale(ranges: LanguageRange[]): Locale {
  const refused = new Set(ranges.filter((r) => r.q === 0).map((r) => r.tag));

  for (const { tag, q } of ranges) {
    if (q === 0) continue;                              // explicitly refused

    // 1. Exact tag match.
    const exact = LOWER.find(([low]) => low === tag);
    if (exact && !refused.has(exact[0])) return exact[1];

    // 2. Primary subtag match — but only within the same script.
    const primary = tag.split('-')[0];
    const sameScript = LOWER.find(([low]) => {
      const [lowPrimary, lowSecond] = low.split('-');
      if (lowPrimary !== primary) return false;
      // Never substitute across scripts: zh-Hant must not answer zh-Hans.
      const tagSecond = tag.split('-')[1];
      if (isScript(lowSecond) && isScript(tagSecond)) return lowSecond === tagSecond;
      return true;
    });
    if (sameScript && !refused.has(sameScript[0])) return sameScript[1];

    // 3. Wildcard: the visitor accepts anything we have.
    if (tag === '*') return DEFAULT_LOCALE;
  }

  return DEFAULT_LOCALE;
}

// A 4-letter secondary subtag is a script (Hans, Hant, Latn, Cyrl).
function isScript(sub: string | undefined): boolean {
  return !!sub && sub.length === 4;
}

The script guard is the piece most implementations miss. Simplified and Traditional Chinese share the zh primary subtag but are not interchangeable for a reader; falling back across them produces text your visitor cannot comfortably read. The same logic protects sr-Latn versus sr-Cyrl.

Step 3 — Route, with the stored preference winning

const LOCALE_COOKIE = 'locale';
const PREFIX_RE = new RegExp(`^/(${SUPPORTED.map((l) => l.toLowerCase()).join('|')})(/|$)`, 'i');

export default function middleware(request: Request): Response | undefined {
  const url = new URL(request.url);
  if (/^\/(_next|assets|api|favicon\.ico|robots\.txt)/.test(url.pathname)) return undefined;
  if (PREFIX_RE.test(url.pathname)) return undefined;      // already routed

  const stored = readCookie(request, LOCALE_COOKIE);
  const locale = isSupported(stored)
    ? stored
    : matchLocale(parseAcceptLanguage(request.headers.get('accept-language')));

  const destination = new URL(
    `/${locale.toLowerCase()}${url.pathname}${url.search}`,
    url.origin,
  );
  return new Response(null, {
    status: 302,
    headers: {
      location: destination.toString(),
      'cache-control': 'no-store',
      vary: 'accept-language, cookie',
    },
  });
}

function isSupported(v: string | null): v is Locale {
  return v !== null && (SUPPORTED as readonly string[]).includes(v);
}

Vary: accept-language on the redirect is correct and cheap, because the redirect itself is no-store. What you must not do is put Vary: accept-language on the content responses — the header is high-entropy and would fragment your cache into near-unique entries per visitor. The prefixed URL is what makes the content cacheable, which is the whole reason to redirect rather than negotiate in place.

URL prefix versus Vary on Accept-Language Encoding the locale in the URL prefix produces one cache entry per locale. Varying on the Accept-Language header instead produces a near-unique entry per visitor because the header is high entropy. Locale in the URL prefix /en/pricing /de/pricing /fr/pricing 3 entries · hit ratio stays high Vary: accept-language /pricing + "en-GB,en;q=0.9" /pricing + "en-US,en;q=0.9,de;q=0.8" /pricing + "fr-CH,fr;q=0.9,en;q=0.8" /pricing + "en;q=0.9,es;q=0.7" /pricing + …thousands more near-unique per visitor · effectively uncached Accept-Language carries far more entropy than the decision it produces. Resolve it once at the edge, then cache the resolved URL.
The redirect is not overhead — it is what converts a high-entropy header into three cacheable objects.

Step 4 — Let the visitor override, permanently

export function setLocale(to: Locale, url: URL): Response {
  const bare = url.pathname.replace(PREFIX_RE, '/').replace(/^\/\//, '/');
  return new Response(null, {
    status: 302,
    headers: {
      location: new URL(`/${to.toLowerCase()}${bare}${url.search}`, url.origin).toString(),
      'set-cookie': `locale=${to}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`,
      'cache-control': 'no-store',
    },
  });
}

Configuration snippet

If you are on Next.js, turn off the framework’s own detection so there is exactly one decision-maker:

// next.config.js
module.exports = {
  i18n: {
    locales: ['en', 'en-GB', 'de', 'fr', 'pt-BR', 'ja', 'zh-Hans', 'zh-Hant'],
    defaultLocale: 'en',
    localeDetection: false,   // middleware owns this
  },
};
export const config = {
  runtime: 'edge',
  matcher: ['/((?!_next|assets|api|favicon.ico|robots.txt).*)'],
};

Local versus production divergence

Behaviour Local dev Edge production
Accept-Language Whatever your one browser sends The full diversity of real headers
Framework detection May silently run before middleware Same, unless explicitly disabled
Redirect caching No shared tier in front A shared tier will store anything cacheable
Intl availability Full ICU in Node Available, but data sets can be trimmed
Cookie Secure May be dropped over plain HTTP Always honoured
Header size Small Some clients send very long lists

Test with a deliberately awkward header rather than your own browser’s. curl -H 'Accept-Language: fr-CH, fr;q=0.9, en;q=0.8' exercises the subtag-fallback path that your everyday browsing never will.

Validating with Vitest

import { describe, expect, it } from 'vitest';

const match = (header: string) => matchLocale(parseAcceptLanguage(header));

describe('locale matching', () => {
  it('falls back from an unsupported region to its language', () => {
    expect(match('fr-CH, fr;q=0.9, en;q=0.8')).toBe('fr');
  });

  it('respects quality ordering over document order', () => {
    expect(match('de;q=0.2, ja;q=0.9')).toBe('ja');
  });

  it('prefers an exact regional match when we ship one', () => {
    expect(match('en-GB, en;q=0.9')).toBe('en-GB');
  });

  it('never substitutes across scripts', () => {
    expect(match('zh-Hant, en;q=0.1')).toBe('zh-Hant');
    expect(match('zh-Hans')).toBe('zh-Hans');
  });

  it('honours an explicit refusal', () => {
    expect(match('de;q=0, en;q=0.5')).toBe('en');
  });

  it('treats a wildcard as the default', () => {
    expect(match('*')).toBe('en');
  });

  it('returns the default for an empty or absent header', () => {
    expect(matchLocale(parseAcceptLanguage(null))).toBe('en');
    expect(match('')).toBe('en');
  });

  it('survives a malformed quality value', () => {
    expect(match('de;q=banana, en;q=0.1')).toBe('de');
  });
});
Locale matching test matrix Seven header inputs map to the locale each must produce: regional fallback, quality ordering, exact regional match, script separation, explicit refusal, wildcard and absent header. fr-CH, fr;q=0.9, en;q=0.8 de;q=0.2, ja;q=0.9 en-GB, en;q=0.9 zh-Hant, en;q=0.1 de;q=0, en;q=0.5 * (header absent) fr ja en-GB zh-Hant en en en subtag fallback quality order exact region script kept refusal honoured wildcard default
Seven inputs cover every branch of the matcher. If a change breaks one row, the suite names which rule regressed.

Common pitfalls and resolutions

Everyone gets the default language — only the first header entry is inspected. Iterate the whole quality-ordered list.

A Traditional Chinese reader gets Simplified — primary-subtag fallback with no script guard. Compare four-letter script subtags before matching.

Cache hit ratio collapsesVary: accept-language on content responses. Move the decision into the URL prefix and drop the Vary.

Framework and middleware disagree — built-in locale detection is still enabled. Disable it and own the decision in one place.

Switcher resets on the next page — the cookie is written without Path=/, so it only applies to the page that set it.

q=0 language still selected — refusals are not filtered. Skip any range with q === 0 and exclude it from fallback.

Production deployment checklist

Frequently Asked Questions

Why not just read the first Accept-Language entry?

Because entries are ordered by quality value, not by position, and the first entry is frequently a regional variant you do not ship. Evaluating only the first is why a visitor sending fr-CH followed by fr ends up with English.

Should I put Vary: accept-language on cached pages?

No. The header is high-entropy and would fragment the cache into near-unique entries per visitor. Encode the locale in the URL prefix instead, so each locale is one clean, highly cacheable object.

Can zh-Hans fall back to zh-Hant?

No. They share the zh primary subtag but use different scripts and are not comfortably readable across, so a naive primary-subtag fallback produces the wrong result. Compare four-letter script subtags before allowing a fallback.

What does q=0 mean and does it matter?

It means the visitor explicitly refuses that language. Most implementations ignore it, which can serve someone the one language they asked not to receive. Filter those ranges out of both selection and fallback.

Should geolocation or Accept-Language pick the language?

Accept-Language, whenever present, because it reflects what the person configured rather than where their packets entered the network. Country is a reasonable last resort only when no stored choice and no language header exist.