Geo-IP and language negotiation conflicts

A German-speaking reader in Zurich is served French, because the geo-IP lookup returned Switzerland and the mapping table picked the largest language. An American consultant working in Tokyo gets Japanese. A reader on a corporate VPN whose exit node is in Ireland gets English prices in euros while sitting in London.

Each of these is a country signal being used to answer a language question.

What each signal is evidence of Geo-IP answers where a request originated and is evidence for currency, tax and product availability. Accept-Language answers what languages the reader can read and is evidence for language. A URL prefix records what was explicitly requested. An account setting records what the person chose, and outranks the inferred signals for both questions. Two questions, two answers Answers Should decide Geo-IP where the request came from currency, tax, availability Accept-Language what the reader can read language URL prefix what was asked for everything, once set Account setting what the person chose both, when present
Geo-IP is a country signal. Using it to choose a language is the whole bug.

Root cause: geography is evidence of country, not of language

Two facts about a reader matter, and they are independent.

Which language they read is a property of the person. The Accept-Language header is the browser’s report of it, an explicit choice is stronger evidence, and a locale in the URL is stronger still because it is what they asked for.

Which country they are transacting in is a property of the context. It determines currency, tax, shipping, legal wording and product availability. Geo-IP is a reasonable first guess at it, and an account setting or an explicit selection is better.

Conflating them fails in both directions. Deriving language from geography misroutes everyone whose language does not match their location — which is a large population, and a growing one. Deriving country from language is worse: a Spanish-speaking reader could be in twenty countries with different currencies and tax rules.

Four populations misrouted by geography-driven language Choosing a language from geography misroutes expatriates, who live in one country and read another language; travellers, whose language has not changed with their location; anyone behind a VPN or a corporate network, whose apparent country is an office; and readers in multilingual countries, where the country implies no single language at all. Who does geo-language routing fail? Reader in Switzerland, browser set to English geo says CH, header says en Expatriates local country, foreign language Travellers language unchanged, country changed VPN and corporate networks country is an office, not a home Multilingual countries country implies no single language
Switzerland alone makes the point: four national languages, and geography cannot choose between them.

Multilingual countries make the point unarguable. Switzerland has four national languages, Belgium three, Canada two, India many. A country-to-language table has to pick one, and picking one is wrong for a substantial share of every one of those populations.

Minimal reproducible example

// A table that cannot be right
const COUNTRY_LANGUAGE: Record<string, string> = {
  CH: 'fr', BE: 'nl', CA: 'en', IN: 'hi', /* … */
};

const country = await geoLookup(request.ip);
const locale = COUNTRY_LANGUAGE[country] ?? 'en';   // the reader's header is never read
return redirect(`/${locale}/`);

The header the browser sent — the one piece of direct evidence about what the reader can read — is discarded before it is examined.

The fix: two chains that never read each other’s signals

// Language: what the reader can read. Geography plays no part.
export function resolveLanguage(req: Request): string {
  const fromPath = supportedLanguage(firstSegment(req.url));
  if (fromPath) return fromPath;                       // explicit, wins outright

  const fromCookie = supportedLanguage(cookie(req, 'locale'));
  if (fromCookie) return fromCookie;                   // a remembered choice

  return negotiate(req.headers.get('accept-language'), SUPPORTED) ?? DEFAULT_LANG;
}

// Country: where the transaction happens. Language plays no part.
export async function resolveCountry(req: Request, session?: Session): Promise<string> {
  if (session?.countryOverride) return session.countryOverride;   // chosen
  if (session?.billingCountry) return session.billingCountry;     // known
  return (await geoLookup(clientIp(req))) ?? DEFAULT_COUNTRY;     // inferred
}

The two functions read disjoint inputs, which is the whole design. A reader can then be German-speaking in Switzerland, English-speaking in Japan, or anything else, and both answers are right.

Two independent resolution chains Language resolves from the URL prefix, then a stored cookie, then the Accept-Language header, and geography plays no part. Country resolves from the account, then an explicit selection, then geo-IP, and language plays no part. Suggestions are offered rather than imposed, dismissals are remembered, and every combination of language and country remains reachable by URL. Resolving both questions without conflating them 1 Language: URL, then cookie, then header geography never enters this chain 2 Country: account, then explicit choice, then geo-IP language never enters this one 3 Offer, do not impose a dismissible banner beats a redirect 4 Remember the dismissal asking twice is worse than not asking 5 Keep every combination reachable German language, Swiss prices, both by URL
Two chains, no crossing. Every conflict in this area comes from one chain reading the other signal.

Suggesting a change without hijacking the visit

Geography is still useful, and the useful shape is a suggestion rather than a redirect.

A reader arriving at a German page from a Swiss address may well want Swiss pricing, and telling them so is helpful. Redirecting them is not, because the redirect discards what they explicitly requested — and if they followed a shared link, it discards what the sender intended them to see.

The pattern that works is a dismissible banner: state what is available, offer the switch, and remember the answer. Three properties make it tolerable. It does not move the reader, so a shared link still lands where it pointed. It states both options, so the reader knows what they are choosing between. And it remembers a dismissal, because a suggestion repeated on every page is an obstacle rather than an offer.

The same applies to the language suggestion, with one addition: the banner offering a language should be written in the language it is offering, since the reader who needs it may not read the one the page is currently in.

Where the country signal genuinely belongs

Separating the chains raises a fair question: if geography does not choose the language, what is it actually for? The answer is a short list, and it is worth writing down because it keeps the signal from creeping back into presentation.

Currency and pricing. What a reader is charged in, and often what they are charged, is a country decision. It affects the checkout rather than the copy.

Tax and legal wording. Rates, inclusive or exclusive display, and the statutory text a jurisdiction requires. These change with the country and not with the language, which is exactly why a German-language page can need Swiss, Austrian or German legal text.

Availability. Whether a product ships, whether a feature is offered, whether a payment method exists. All country properties.

Compliance and consent. Which consent flow applies, what a privacy notice must say, whether a feature is permitted at all.

Every item on that list is about the transaction rather than about the reading, and each of them is better answered by an explicit account setting than by an inference. Geo-IP earns its place as the default for the first visit, before the reader has told you anything — and once they have, it should stop being consulted.

The practical test for whether a signal is in the right chain is to ask what changes when a reader travels. Their language does not; their currency might. Anything that should change when they board a plane is a country concern, and anything that should not is a language concern.

Verification

test('an English header from a French address stays English', async () => {
  const res = await fetch('/', {
    headers: { 'accept-language': 'en-GB', 'x-forwarded-for': FRENCH_IP },
  });
  expect(res.headers.get('location')).toBe('/en/');
});

test('country and language resolve independently', async () => {
  const req = mockRequest({ acceptLanguage: 'de-DE', ip: SWISS_IP });
  expect(resolveLanguage(req)).toBe('de');
  await expect(resolveCountry(req)).resolves.toBe('CH');   // both, not one from the other
});

test('an explicit URL is never overridden by geography', async () => {
  const res = await fetch('/ja/pricing', { headers: { 'x-forwarded-for': GERMAN_IP } });
  expect(res.status).toBe(200);
});

The third assertion is the one that catches a geography rule reintroduced later — it is the property a reader relies on when they share a link.

When to escalate

If geography is correct and readers still see the wrong currency, the country is resolving from a different source than you think. Logging both resolved values on the request is the fastest way to see which chain produced which answer.

If crawlers only ever index one language, a geography-based redirect is probably still active for them: crawlers request from a small set of locations, so a geographic rule shows them one variant — the indexing failure described in locale-aware SEO and hreflang.

If the geo-IP lookup itself is unreliable, treat that as expected rather than fixable. Mobile networks, VPNs and corporate egress all produce a country that is not where the person is, which is one more reason the signal should suggest rather than decide.

FAQ

Is geo-IP ever the right way to choose a language?

Only as a last resort when no other signal exists — no path, no cookie, and no usable header — and even then it should land on a page offering a choice rather than committing silently. In practice the header is present almost always, so the case is rare.

Should the country be in the URL like the language?

If it changes the content — prices, availability, legal text — yes, for the same reasons the language is: a shared link should carry it and a crawler should be able to see each variant. If it only affects a checkout that requires an account anyway, a stored setting is enough.

How should a VPN user be handled?

Exactly like everyone else: the inferred country is a suggestion they can override. There is no way to detect a VPN reliably, and no need to — an override that anyone can use covers it.

What about legal requirements to block or route by country?

Those are enforcement rules and belong at the edge, applied to access rather than to language. Keeping them separate from presentation means a blocked region gets a clear message rather than a silently different site.

Does splitting the chains mean two URL segments?

Not necessarily. The language belongs in the path because it changes the content of every page; the country often does not need to, because it changes a smaller surface. A single language prefix with the country held in an account setting is a common and defensible arrangement — and if the country changes visible prices, it belongs in the URL too, for the same shareability and indexing reasons.

How should the two interact on a first visit with no account?

Independently, and both as guesses. The header suggests a language, geography suggests a country, and the reader lands on a page reflecting both with a way to change either. What matters is that neither guess overrides an explicit request already present in the URL.

Part of Locale Negotiation Strategies.