Engineering
Reading the visitor's language: Accept-Language, q-values, and refining by country
Our marketing site serves regional markets like en-NZ and de-DE, and the bare URL has to redirect each visitor to the right one. We used to do that by IP country alone, which sent a German tourist in New York to an English page. Now we read the browser's own language preference first, refine it by where they are, and we moved the language dropdown to the footer because the page usually guesses right.
The short version
- The marketing site has one regional URL per market, like /en-nz and /de-de, and the bare / has to pick one.
- Old rule: redirect by IP country. A German speaker travelling in the US landed on an English page.
- New rule: read the browser's Accept-Language, which is a ranked list, and serve the top language we support.
- We refine the choice by country, so an English speaker in Australia gets en-au rather than the default English market.
- An explicit choice still wins: if the visitor picked a language before, the saved cookie beats the guess.
Bookatu's marketing pages run as a set of regional markets. Each has a URL prefix, like /en-nz for New Zealand English or /de-de for German, and each carries the right currency examples, spelling and hreflang tags for search engines. When someone lands on the bare address with no prefix, something has to choose a market for them and redirect. Get it wrong and the first thing a visitor sees is a page in the wrong language or quoting the wrong currency, which is a bad first impression to lead with.
Geo alone gets it wrong
The old redirect used the IP country and nothing else. That is right often enough to feel fine in testing and wrong often enough to annoy real people. A German speaker on holiday in New York got an English page because the IP said US. Someone using a VPN got the exit node's country. Location is a decent hint about currency and region, but it is a poor guess at language, and language is the part the visitor actually feels.
Accept-Language is a ranked list, not a string
The browser already ships the answer on every request. The Accept-Language header is the visitor's own ordered language preference, complete with weights. A header like de-DE,de;q=0.9,en-US;q=0.8 means German first, then English as a fallback. The common mistake is to grab the first token and stop, which ignores the weights and the fallbacks. So the first job is to parse it properly: split it, read each q-value, sort by weight, strip the region down to the base language, lower-case it, and drop duplicates.
// "de-DE,de;q=0.9,en-US;q=0.8" -> ["de", "en"]
export function parseAcceptLanguage(header: string | null | undefined): string[] {
if (!header) return [];
const ranked = header
.split(",")
.map((part) => {
const [tag, ...params] = part.trim().split(";");
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
const weight = q ? Number(q.slice(2)) : 1;
const lang = (tag || "").trim().toLowerCase().split("-")[0];
return { lang, weight: Number.isFinite(weight) ? weight : 1 };
})
.filter((x) => x.lang && x.lang !== "*")
.sort((a, b) => b.weight - a.weight);
const out: string[] = [];
for (const { lang } of ranked) if (!out.includes(lang)) out.push(lang);
return out;
}Language first, country as a refinement
With a ranked list of languages, the market choice gets simple to state. Walk the list. Take the first language we actually ship a market for. If the visitor's country has a market that speaks that same language, use the country's market, so an English speaker in Australia gets en-au and not the default English. Otherwise use the canonical market for the language. If none of their languages is one we support, fall back to the country's market, and if that fails too, the global English default. The cookie check sits above all of this, so a visitor who has already chosen a language is never second-guessed.
export function marketForVisitor(
acceptLanguage: string | null | undefined,
country: string | null | undefined,
): string {
const geoMarket = country ? COUNTRY_TO_MARKET[country.toUpperCase()] : undefined;
for (const lang of parseAcceptLanguage(acceptLanguage)) {
if (!MARKET_BY_LANGUAGE.has(lang)) continue; // not a language we ship
if (geoMarket && LANGUAGE_BY_MARKET.get(geoMarket) === lang) return geoMarket; // refine to country
return languageToMarket(lang); // else the canonical market
}
return marketForCountry(country); // no usable language: geo, then en-us
}Why the dropdown moved to the footer
Once the page guesses right most of the time, a language switcher pinned to the top of the header is clutter selling certainty the visitor rarely needs. So we moved it to the footer. It is still there for the person who wants German content from an English machine, or who is on a shared computer, but it no longer competes with the things people actually came to do. One small detail: a menu in the footer has to open upward, or it gets clipped at the bottom of the page, so the component grew a flag that flips its origin and direction. Small thing, but a menu you cannot read is worse than no menu.
The cases worth a test
- Weights are honoured: en;q=0.5,de;q=0.9 resolves to German first, not English, even though English is written first.
- Language beats location: a German browser physically in the US still gets the German market.
- Refinement works: an English browser in Australia gets en-au, not the default English market.
- Graceful fallback: an unsupported language falls back to the country's market, and no signal at all falls back to global English.
None of this is exotic. The header has been on every request the whole time. The lesson, if there is one, is to use the signal the visitor is already sending before reaching for the one you have to infer, and to keep the manual control for the cases the guess cannot cover.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs