Geolocation and Personalization at the Edge

This guide is part of Middleware Chain Architecture & Request Flow. It covers the one thing an edge function knows that your origin does not: where the request physically came from — and how to turn that into routing, currency, language and content decisions without destroying your cache.

Geolocation is the classic argument for edge compute. The PoP that terminates the connection already knows the visitor’s country, region and approximate coordinates, resolved from the client IP by the network itself. No lookup, no third-party API, no latency. The temptation is to use it everywhere. The discipline is to use it in exactly the places where it changes the answer, because every personalization dimension you introduce multiplies your cache keyspace.

What the platform actually gives you

Each provider surfaces geo data differently, and the fields are not equivalent in accuracy or availability.

Signal Cloudflare Workers Vercel Edge Middleware Netlify Edge Functions
Country (ISO 3166-1 alpha-2) request.cf.country request.geo.country context.geo.country.code
Region / subdivision request.cf.region, regionCode request.geo.region context.geo.subdivision.code
City request.cf.city request.geo.city context.geo.city
Coordinates request.cf.latitude/longitude request.geo.latitude/longitude context.geo.latitude/longitude
Timezone request.cf.timezone not exposed not exposed
Continent request.cf.continent not exposed context.geo.country.code only
PoP identifier request.cf.colo request.headers.get('x-vercel-id') context.site.id + logs
Accuracy Country very high; city approximate Country very high; city approximate Country very high; city approximate

Country is the only field accurate enough to make binding decisions on. City and coordinates are IP-derived estimates: correct often enough to personalize a “nearest store” widget, wrong often enough that you must never gate access or compute tax on them. Corporate VPNs, mobile carrier NAT and satellite links routinely place a visitor a country away.

It is worth being precise about where the data comes from, because that explains the accuracy profile. The provider maps the client IP address to a location using registry allocations and routing observations. Address blocks are allocated to organisations at country granularity, so the country attribution is essentially administrative fact and rarely wrong. Everything below that — region, city, coordinates — is inference from where traffic for that block has been observed entering the network, and it degrades exactly where address blocks are large or mobile: carrier-grade NAT pools serving a whole country from one city, enterprise VPN concentrators, satellite uplinks, and cloud egress addresses that belong to a data centre rather than a person.

The practical consequence is a two-tier policy. Treat country as reliable enough to pick a default, and treat everything finer as a display convenience that must degrade gracefully when absent or wrong. A “stores near you” module built on approximate coordinates is fine because a wrong answer is merely unhelpful. A tax calculation, an age gate or a licensing restriction built on the same data is not fine, because a wrong answer is a compliance failure, and the visitor has no way to correct it unless you give them one.

Geo resolution at the point of presence The network terminates the connection at the nearest point of presence and attaches country, region and city to the request before middleware runs, so no lookup is needed. DE BR JP visitors Nearest PoP terminates TLS resolves client IP country: high confidence region: good city: approximate no extra lookup locale routing /de/, /pt-br/, /ja/ currency + tax hint display only consent regime banner variant
The geo fields arrive free with the request. What costs you is what you do with them downstream.

Normalize the provider differences behind one function

Write the adapter once. Every downstream decision then reads a single shape, and porting between providers becomes a one-file change.

export interface GeoContext {
  country: string;          // ISO 3166-1 alpha-2, uppercase, 'XX' when unknown
  region: string | null;
  city: string | null;
  confident: boolean;       // false when the provider gave us nothing usable
}

const UNKNOWN: GeoContext = { country: 'XX', region: null, city: null, confident: false };

export function readGeo(request: Request): GeoContext {
  // Cloudflare
  const cf = (request as { cf?: Record<string, unknown> }).cf;
  if (cf && typeof cf.country === 'string') {
    return {
      country: cf.country.toUpperCase(),
      region: typeof cf.region === 'string' ? cf.region : null,
      city: typeof cf.city === 'string' ? cf.city : null,
      confident: cf.country !== 'T1',       // T1 = Tor exit node
    };
  }

  // Vercel exposes the same data as request headers on the edge runtime.
  const country = request.headers.get('x-vercel-ip-country');
  if (country) {
    return {
      country: country.toUpperCase(),
      region: request.headers.get('x-vercel-ip-country-region'),
      city: request.headers.get('x-vercel-ip-city'),
      confident: true,
    };
  }

  return UNKNOWN;
}

The XX sentinel is deliberate. Returning null for an unknown country forces every call site to handle absence, and one of them will forget. A defined sentinel that never matches a real rule makes the fallback path the default.

Locale negotiation: geography is a hint, not an answer

Country tells you where the connection originated. It does not tell you what language the person reads. A German-speaking visitor travelling in Brazil should not get Portuguese. The correct precedence is:

  1. An explicit choice the visitor already made, stored in a cookie. Always wins.
  2. Accept-Language, which the browser sends and reflects the user’s actual configured preferences.
  3. Country, as a last-resort default.
Locale precedence ladder An explicitly stored choice always wins. The Accept-Language header comes second because it reflects configured preferences. The country default is only consulted when neither is available. 1 · Stored choice the visitor told you — never override it 2 · Accept-Language what the browser is configured to read 3 · Country default where the packets entered the network — a guess weaker Checking these in the wrong order is why sites argue with their own users.
Country is the weakest signal in the stack, yet it is the one most implementations check first.
const SUPPORTED = ['en', 'de', 'fr', 'pt-BR', 'ja'] as const;
type Locale = (typeof SUPPORTED)[number];

const COUNTRY_DEFAULT: Record<string, Locale> = {
  DE: 'de', AT: 'de', CH: 'de',
  FR: 'fr', BE: 'fr',
  BR: 'pt-BR',
  JP: 'ja',
};

export function negotiateLocale(request: Request, geo: GeoContext): Locale {
  // 1. Explicit choice.
  const stored = parseCookies(request.headers.get('cookie')).get('locale');
  if (stored && (SUPPORTED as readonly string[]).includes(stored)) return stored as Locale;

  // 2. Accept-Language, highest q-value first.
  for (const tag of parseAcceptLanguage(request.headers.get('accept-language'))) {
    const exact = SUPPORTED.find((l) => l.toLowerCase() === tag.toLowerCase());
    if (exact) return exact;
    const base = SUPPORTED.find((l) => l.split('-')[0] === tag.split('-')[0]);
    if (base) return base;
  }

  // 3. Country default.
  return COUNTRY_DEFAULT[geo.country] ?? 'en';
}

function parseAcceptLanguage(header: string | null): string[] {
  if (!header) return [];
  return header
    .split(',')
    .map((part) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params.find((p) => p.trim().startsWith('q='));
      return { tag: tag.trim(), q: q ? Number(q.split('=')[1]) : 1 };
    })
    .filter((e) => e.tag && !Number.isNaN(e.q))
    .sort((a, b) => b.q - a.q)
    .map((e) => e.tag);
}

The cache cost of personalization

This is where most geo implementations go wrong. A response that differs by country is no longer one cacheable object — it is one per country you serve. Adding a second dimension, say currency, multiplies again. The hit ratio falls, origin load rises, and the edge you added for speed makes the site slower.

Three rules keep it under control.

Bucket aggressively. Do not key on country; key on the decision the country produced. If forty countries all resolve to en + EUR, the cache key dimension is en-EUR, one entry, not forty. Compute the bucket in middleware and emit it as a single normalized header.

Prefer redirects to variants for large-grain differences. Sending a German visitor to /de/ gives you a distinct URL that caches perfectly, with no Vary at all. Variant responses under one URL are appropriate only for small differences that do not justify a separate address.

Never Vary: cookie. It fragments per visitor, which is the same as not caching. If personalization genuinely requires per-user data, mark the response private and cache the shell instead, filling the personal part client-side.

// Emit ONE normalized dimension, not the raw geo fields.
export function personalizationKey(locale: Locale, geo: GeoContext): string {
  const currency = CURRENCY_BY_COUNTRY[geo.country] ?? 'USD';
  return `${locale}|${currency}`;         // e.g. "de|EUR" — a handful of values
}
Raw country keys versus bucketed decision keys Keying the cache on the raw country code creates one cache entry per country. Bucketing countries into the locale and currency decision they produce collapses them into a handful of entries with a far higher hit ratio. Key on raw country Key on the decision DEATCHFR BEITESNL PTIEFI 40+ entries · low hit ratio de|EURfr|EUR en|EURen|USD 4 entries · high hit ratio bucket Cache the decision, not the input that produced it.
Forty country keys and four decision keys serve identical content. Only one of them keeps your origin idle.

Control-flow variants

Redirect once, then respect the URL. A visitor who lands on / from Germany gets a 302 to /de/. A visitor who explicitly navigates to /en/ must not be bounced back — the redirect applies only to the un-prefixed root, or you build an infinite loop for anyone who wants the other language.

Rewrite instead of redirect when you want to keep the URL clean. The visitor stays on /pricing, but the edge rewrites internally to /de/pricing. Cheaper (no extra round trip) but the visitor cannot bookmark or share the specific variant, and your cache key must now include the locale dimension.

Soft signals for A/B and rollout. Geography is a natural cohort boundary for a staged rollout: enable a feature in one country, watch the metrics, widen. Combine it with the deterministic bucketing described in running A/B tests at the edge.

Regulatory gating. Consent-banner variants, age gates and feature availability sometimes must differ by jurisdiction. Use country for the default, never as proof — an IP-derived country is not a legal determination, and a visitor can override it. Provide an explicit control and store the choice.

Framework integration

Next.js has first-class locale routing, but its built-in Accept-Language detection runs before your middleware and can fight it. Disable automatic detection (i18n.localeDetection: false) and own the decision in middleware, so there is exactly one place that decides.

Remix works well with a rewrite: resolve the locale in edge middleware, inject it as a header, and have the root loader read it into context for the whole tree.

SvelteKit with adapter-cloudflare exposes the platform object, so event.platform?.cf?.country is available in handle, letting you populate event.locals before any route runs.

Matcher and adapter specifics are covered in framework-specific routing patterns.

Debugging workflow

Local development has no geo data at all — every field is undefined, so your fallback path is the only one you ever exercise. Inject a fake context from an environment variable so you can develop each branch, and make the override impossible in production.

export function readGeoWithOverride(request: Request, env: { GEO_OVERRIDE?: string; ENVIRONMENT?: string }): GeoContext {
  if (env.ENVIRONMENT !== 'production' && env.GEO_OVERRIDE) {
    return { country: env.GEO_OVERRIDE.toUpperCase(), region: null, city: null, confident: true };
  }
  return readGeo(request);
}

In production, log the resolved country and the resulting bucket — never the client IP, which is personal data in most jurisdictions. A distribution of buckets over time tells you immediately whether your bucketing is as coarse as you intended, or whether a rule is accidentally creating a scattering of near-empty variants.

Common pitfalls

Symptom Root cause Fix
Redirect loop between / and /de/ The redirect rule also matches already-prefixed paths Apply the rule only to the un-prefixed root
Cache hit ratio collapses after launch Raw country used as a cache dimension Bucket to the decision; key on `locale
Everyone sees the fallback locale Local dev geo is undefined and the same code shipped Handle XX explicitly and test each branch with an override
VPN users get the wrong country IP geolocation is an estimate, not ground truth Provide an explicit switcher; never gate on geo alone
One user’s country leaks to another Personalized response cached publicly Bucket into the key, or mark private
Language ignores the user’s browser Country checked before Accept-Language Cookie, then Accept-Language, then country
Costs spike on a static route Middleware runs on assets Tighten the matcher

Runtime-constraints checklist

Frequently Asked Questions

How accurate is edge IP geolocation?

Country resolution is highly accurate in practice. Region is good, city and coordinates are estimates that can be off by hundreds of kilometres for corporate VPNs, mobile carrier NAT and satellite connections. Use country for decisions and treat everything finer as a display hint.

Should I redirect or rewrite for locale routing?

Redirect when you want a distinct, shareable, perfectly cacheable URL per locale, which is usually the right answer for content sites. Rewrite when the URL must stay clean, accepting that the locale then becomes a cache-key dimension you have to manage.

Why did my cache hit ratio fall after adding geo personalization?

Almost certainly because the raw country code became part of the cache key, creating one entry per country for identical content. Bucket countries into the decision they produce, such as locale and currency, and key on that instead.

Can I use geolocation to enforce regional restrictions?

You can use it as a default, but not as proof. IP-derived country is an estimate that a VPN trivially defeats and that misclassifies legitimate visitors. Anything with legal or contractual weight needs an authoritative signal such as a verified billing address.

Why is geo data undefined in local development?

There is no point of presence resolving a client IP locally, so the provider fields are simply absent. Inject a fake context from an environment variable during development, and guard the override so it cannot be set in production.

Should country or Accept-Language decide the language?

Accept-Language, whenever it is present. It reflects what the person configured in their browser, whereas country reflects where the packet entered the network. Country is only a sensible last resort when no explicit choice and no language header are available.