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.
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.
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');
});
});
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 collapses — Vary: 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.