Serving Localized Content with Edge Geolocation

After working through this guide you will be able to resolve a locale at the edge from the platform’s country signal combined with the browser’s Accept-Language header, choose per route between redirecting to a localized URL and rewriting the response in place, persist an explicit user choice in a cookie that outranks both automatic signals, keep the cache key honest so one country’s page is never served to another, and publish hreflang markup plus a visible switcher so every variant stays reachable.

Localization is where geolocation stops being an infrastructure concern and starts being a product one. Picking a region for a backend is forgiving — the user never sees which origin answered. Picking a language and a currency is not: get it wrong and a German visitor lands on dollar pricing, a Canadian sees European VAT, and a search crawler indexes exactly one of the variants you spent months writing. This guide treats the locale decision as a small, ordered resolver with an explicit escape hatch, rather than as a country lookup wired directly into a redirect. The routing mechanics underneath are the same ones covered in Geo-Targeted Traffic Routing; what changes here is that the output is user-visible content, so every decision needs to be reversible by the person reading the page.

Key implementation objectives:

  • Resolve a locale from an ordered precedence chain in which geolocation is the last automatic signal, not the first.
  • Decide per route whether a localized URL prefix (/de/) or an in-place rewrite is the correct delivery mechanism, and understand what each costs you in search.
  • Store an explicit user override in a first-party cookie and make it beat the country signal on every subsequent request.
  • Partition the edge cache by the locale bucket that actually varies the response body, and no finer.
Locale precedence chain Four ordered locale signals evaluated at the edge: an explicit cookie override, an existing path prefix, the Accept-Language header, and finally the geolocation country code with a default fallback. First match wins — geolocation is evaluated last 1. Explicit override cookie locale=fr-CA, set by the visible switcher 2. Locale already in the path /de/preise — honour it, never re-steer 3. Accept-Language q-values de-DE;q=0.9, en;q=0.7 — a stated preference 4. Country code, then default cf.country = AT, else the site default wins outright stable for crawlers from the browser weakest signal no cookie bare path header absent

Prerequisites and environment setup

You need a hostname proxied through the edge network so a country code is actually attached to the request. On Cloudflare that means an orange-clouded record and Wrangler 3.60 or newer; on Vercel it means a project on a plan where the x-vercel-ip-country header is injected. Confirm both the toolchain and the signal before writing any resolver logic — half the “geolocation is broken” reports resolve to a grey-clouded record.

npx wrangler --version          # 3.60.0 or newer
curl -s https://www.example.com/cdn-cgi/trace | grep -E '^(loc|colo)='

Expected output names the country the edge assigned to your current IP and the point of presence that answered:

loc=NL
colo=AMS

You also need a content model that can actually produce more than one locale. That means a translated route table (which paths exist in which language), a currency and tax rule per market, and a canonical default for anything untranslated. Decide these before touching the edge, because the resolver’s job is only to pick a key — it must never invent a locale that has no content behind it.

Requirement Cloudflare Vercel
Country signal request.cf.country x-vercel-ip-country header
Region / city detail request.cf.region, request.cf.city x-vercel-ip-country-region, x-vercel-ip-city
Spoofable by the client No — overwritten at the edge No — injected at the edge
Missing-data sentinel XX (unknown), T1 (Tor) header absent
Supported locale list Worker module constant or KV i18n.locales in the framework config

Step-by-step procedure

Step 1 — Resolve a locale from geolocation and Accept-Language

Two automatic signals disagree more often than people expect. A country code says where the network thinks the user is; Accept-Language says what the user configured their browser to read. A Turkish speaker living in Berlin sends tr-TR from a German IP, and the browser is right. So treat Accept-Language as the stronger of the two automatic signals and the country code as a tiebreaker that mainly decides currency and market, not language.

Parse the header properly. It is a quality-value list, not a single value, and the highest q wins — naïvely taking the first two characters produces the wrong answer whenever a browser lists a regional variant first with a low weight.

const SUPPORTED = ['en', 'de', 'fr', 'ja'];
const DEFAULT_LOCALE = 'en';

// Country -> market locale. Only used when the browser tells us nothing useful.
const LOCALE_BY_COUNTRY = new Map([
  ['DE', 'de'], ['AT', 'de'], ['CH', 'de'],
  ['FR', 'fr'], ['BE', 'fr'], ['LU', 'fr'],
  ['JP', 'ja'],
]);

function parseAcceptLanguage(header) {
  if (!header) return [];
  return header
    .split(',')
    .map((part) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params
        .map((p) => p.trim())
        .filter((p) => p.startsWith('q='))
        .map((p) => Number(p.slice(2)))[0];
      return { tag: tag.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
    })
    .filter((entry) => entry.tag && entry.tag !== '*')
    .sort((a, b) => b.q - a.q);
}

function negotiateLocale({ acceptLanguage, country }) {
  for (const { tag } of parseAcceptLanguage(acceptLanguage)) {
    const base = tag.split('-')[0];
    if (SUPPORTED.includes(base)) return { locale: base, source: 'accept-language' };
  }
  const byCountry = LOCALE_BY_COUNTRY.get((country || '').toUpperCase());
  if (byCountry) return { locale: byCountry, source: 'geo' };
  return { locale: DEFAULT_LOCALE, source: 'default' };
}

The source field is the part people leave out and then miss. Emit it as a response header (x-locale-source: geo) and every later question — why did this user get German, why is the cache hit ratio down, why does this crawler see English — becomes a log query instead of a guess.

Step 2 — Redirect to a localized URL, or rewrite in place

This is the decision with the longest tail of consequences, so make it deliberately rather than by copying whichever snippet you found first.

A redirect sends 302 (or 301) to a distinct URL such as /de/preise. Each locale then has its own address, which is exactly what a search engine wants: it can crawl, index, and rank the German page separately from the English one. The failure mode is doing it unconditionally. A crawler requests /pricing from a US data center, gets bounced to /en/pricing, and never sees /de/pricing at all unless something else links to it. Worse, a crawler that follows the redirect records the bare URL as a permanent alias of the English page, so the German content ends up with no indexed entry point. An unconditional geo redirect also breaks any deep link a user shares across borders: paste a /de/ URL into a chat and your American colleague is thrown back to /en/, which reads as the site refusing to show them what you sent.

A rewrite keeps the URL the visitor requested and swaps the response body behind it. There is no extra round trip and no shared-link breakage, but a single URL now returns different content to different people — which means the URL cannot rank for more than one language, and any shared cache in front of you must be told that the response varies. Rewriting is the right choice for currency, tax display, and stock availability; a localized URL is the right choice for translated prose. The rewrite mechanics are the same ones described in rewriting URLs and paths at the edge, applied here to a locale segment instead of an origin path.

Redirect versus rewrite as a crawler sees it Two lanes follow the same crawler request. The upper lane redirects on country and leaves only one locale indexed; the lower lane rewrites in place and exposes every locale through hreflang. Unconditional geo redirect Crawler, US IP GET /pricing Country check country = US 302 issued Location: /en/pricing One locale indexed /de/ never fetched Rewrite in place plus hreflang Crawler, US IP GET /pricing Country check bots exempted 200, URL kept body swapped All locales listed hreflang crawled Redirect only after the path is already locale-free and the visitor has no stated preference

The workable compromise is a redirect on the bare path only, once. If the request already carries a locale prefix, serve it verbatim. If it does not, and the visitor has no cookie and no usable Accept-Language, then a redirect is acceptable — but exempt known crawler user agents so they always receive the un-steered page and discover the rest through hreflang.

const LOCALE_PREFIX = /^\/(en|de|fr|ja)(\/|$)/;
const CRAWLER = /(googlebot|bingbot|duckduckbot|applebot|yandexbot)/i;

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // 1. The path already states a locale: never re-steer it.
    if (LOCALE_PREFIX.test(url.pathname)) return fetch(request);

    // 2. Crawlers get the bare path untouched and follow hreflang instead.
    if (CRAWLER.test(request.headers.get('user-agent') || '')) {
      const res = await fetch(request);
      const out = new Response(res.body, res);
      out.headers.set('x-locale-source', 'crawler-neutral');
      return out;
    }

    const { locale, source } = negotiateLocale({
      acceptLanguage: request.headers.get('accept-language'),
      country: request.cf?.country,
    });

    // 3. Human visitor on a bare path: one redirect into the localized URL.
    url.pathname = `/${locale}${url.pathname}`;
    const redirect = Response.redirect(url.toString(), 302);
    const headers = new Headers(redirect.headers);
    headers.set('x-locale-source', source);
    headers.set('Cache-Control', 'no-store');
    return new Response(null, { status: 302, headers });
  },
};

Cache-Control: no-store on the redirect matters as much as the redirect itself. A cached 302 is a redirect frozen for one country and replayed to everyone who shares that cache key, which is the single most common way a localization rollout turns into an incident.

The framework-native equivalent is shorter because the locale segment is already part of the route table. In Vercel Edge Middleware, rewrite keeps the address bar untouched while redirect changes it, and the choice between the two is the same one argued above:

import { NextRequest, NextResponse } from 'next/server';

export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] };

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  if (/^\/(en|de|fr|ja)(\/|$)/.test(pathname)) return NextResponse.next();

  const override = req.cookies.get('locale')?.value;
  const country = req.headers.get('x-vercel-ip-country') ?? '';
  const locale = override ?? pickLocale(req.headers.get('accept-language'), country);

  const url = req.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  // Currency-only differences: rewrite. Translated prose: redirect.
  return pathname.startsWith('/pricing')
    ? NextResponse.rewrite(url)
    : NextResponse.redirect(url, 302);
}

Every automatic signal is a guess, so the resolver needs a value that ends the guessing. Write the user’s choice to a first-party cookie the moment they use the switcher, and read it before anything else.

function readOverride(request) {
  const jar = request.headers.get('cookie') || '';
  const match = jar.match(/(?:^|;\s*)locale=([a-z]{2}(?:-[A-Z]{2})?)/);
  const value = match?.[1]?.split('-')[0];
  return SUPPORTED.includes(value) ? value : null;
}

function setOverride(response, locale) {
  response.headers.append(
    'Set-Cookie',
    `locale=${locale}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`
  );
  return response;
}

function resolveLocale(request) {
  const override = readOverride(request);
  if (override) return { locale: override, source: 'cookie' };
  return negotiateLocale({
    acceptLanguage: request.headers.get('accept-language'),
    country: request.cf?.country,
  });
}

SameSite=Lax is deliberate: the cookie must survive a top-level navigation from a search result or a shared link, which SameSite=Strict would suppress on the very first click — precisely the request where the override matters most. Leave HttpOnly off if client-side code needs to read the current locale to highlight the switcher; the value is a public preference, not a credential. Give it a long Max-Age (a year is normal) so a returning visitor is never re-steered, and validate the value against the supported list before using it, since a cookie is user-controlled input and an unvalidated locale becomes a path-traversal vector the moment you interpolate it into a URL.

Step 4 — Keep the cache key honest

The moment one URL can return two bodies, your cache needs to know. There are two mechanisms and they are not interchangeable.

Vary is the standards-track answer: the origin declares which request headers participate in the match, and a conforming cache stores one variant per distinct header value. It is correct, and on Accept-Language it is also close to useless, because real browsers emit dozens of distinct header strings (en-GB,en;q=0.9,de;q=0.8 and en-GB,en;q=0.9 are different keys for identical content). Varying on the raw header shreds the hit ratio into per-browser fragments.

A bucketed cache key solves that by normalizing first: collapse the request down to the small set of values that actually change the body — the resolved locale, or a coarse market bucket — and put that in the key. Cloudflare exposes country as a cache-key component, and a Worker can construct the key explicitly. This is the same normalization discipline described in customizing cache keys to improve hit ratio: vary on the decision, never on the raw input.

Bucketed locale cache key Raw request signals are normalized into a two-value locale bucket, which produces separate cache entries per locale while an unbucketed key stores a single entry that leaks across markets. Normalize before the key, not after Raw signals header + country + cookie normalize() DE,AT,CH to de in key: /pricing | loc=de euro prices, German copy key: /pricing | loc=en dollar prices, English copy key: /pricing (no bucket) first fill wins, leaks to everyone Two buckets, two entries — varying on the raw Accept-Language string would create hundreds
async function localizedFetch(request, locale) {
  const cache = caches.default;
  const url = new URL(request.url);
  // The bucket, not the raw header, is what distinguishes the stored bodies.
  const cacheKey = new Request(`${url.origin}${url.pathname}?__loc=${locale}`, {
    method: 'GET',
    headers: { accept: request.headers.get('accept') || 'text/html' },
  });

  let response = await cache.match(cacheKey);
  if (!response) {
    response = await fetch(request, { cf: { cacheEverything: false } });
    response = new Response(response.body, response);
    response.headers.set('Cache-Control', 'public, max-age=0, s-maxage=300');
    response.headers.set('x-locale', locale);
    await cache.put(cacheKey, response.clone());
  }
  return response;
}

Two rules keep this safe. First, any response that reflects a cookie override must be private or no-store — the override is per user and has no business in a shared cache. Second, never let the bucket be finer than the content: if only German, French, English, and Japanese bodies exist, four keys is the correct number, and adding country to the key as well multiplies your storage by thirty for zero content difference. If regional routing already partitions your traffic, reuse that partition rather than inventing a second one — see implementing geo-routing with edge functions for latency reduction for how the region decision is made upstream.

Step 5 — Publish hreflang and a visible switcher

hreflang is how a crawler learns that the four localized URLs are alternates of one another rather than duplicates. Every variant must list every other variant including itself, and the set must be reciprocal — a page that claims an alternate which does not claim it back is ignored. Add x-default for the un-steered entry point so the bare path has a defined role.

<link rel="alternate" hreflang="en" href="https://www.example.com/en/pricing" />
<link rel="alternate" hreflang="de" href="https://www.example.com/de/preise" />
<link rel="alternate" hreflang="fr" href="https://www.example.com/fr/tarifs" />
<link rel="alternate" hreflang="ja" href="https://www.example.com/ja/pricing" />
<link rel="alternate" hreflang="x-default" href="https://www.example.com/pricing" />
<link rel="canonical" href="https://www.example.com/de/preise" />

The canonical on each variant points at itself, not at the English page. Pointing every locale’s canonical at one URL tells the crawler the other locales are duplicates and asks it to drop them.

Finally, render a switcher that is visible without JavaScript and links directly to each alternate URL. It is both the accessibility answer and the operational one: when the country signal is wrong, the switcher is how the user fixes it, and clicking it is what writes the override cookie from Step 3. A locale chosen for the user with no visible way to change it is a bug report waiting to be filed.

Verification

Spoof the language first, since Accept-Language is a client header and travels honestly:

curl -sI https://www.example.com/pricing \
  -H 'Accept-Language: de-DE,de;q=0.9,en;q=0.4' \
  | grep -iE 'location|x-locale|cache-control'

Expected output for a bare path with no cookie:

location: https://www.example.com/de/preise
x-locale-source: accept-language
cache-control: no-store

Country is derived from the connecting IP and cannot be forged with a header on a live request, so verify it against a staging route that trusts an injected value, or from a real exit IP:

curl -sI https://staging.example.com/pricing \
  -H 'CF-IPCountry: JP' -H 'Accept-Language: *' \
  | grep -i 'x-locale'
# x-locale-source: geo
# x-locale: ja

Confirm the override wins over both automatic signals:

curl -sI https://www.example.com/pricing \
  -b 'locale=fr' -H 'Accept-Language: de-DE,de;q=0.9' \
  | grep -iE 'location|x-locale-source'
# location: https://www.example.com/fr/tarifs
# x-locale-source: cookie

Prove the cache is partitioned by requesting the same path twice under two locales and comparing bodies and cache status:

for loc in de en; do
  curl -s -D - -o /dev/null "https://www.example.com/pricing" \
    -b "locale=$loc" | awk -F': ' 'tolower($1) ~ /x-locale|cf-cache-status/ {print $2}' | tr -d '\r'
done

Two different x-locale values with independent HIT/MISS counters means the key is bucketed correctly. Identical values mean the locale is not in the key and you are one request away from serving the wrong market. Finally, replay a crawler user agent and confirm it is not redirected:

curl -sI https://www.example.com/pricing \
  -A 'Mozilla/5.0 (compatible; Googlebot/2.1)' | head -1
# HTTP/2 200

Troubleshooting

A VPN or corporate proxy puts the user in the wrong country

Diagnosis: /cdn-cgi/trace reports a loc that does not match where the user says they are, and the reported IP belongs to a hosting provider rather than a consumer network. There is no header that recovers the true location — the edge only ever sees the exit node. Fix: this is exactly what the precedence chain is for. Accept-Language usually still reflects the real user, so it should already outrank the country code, and the switcher plus override cookie makes the remaining cases self-service. Do not attempt to detect VPNs and second-guess them; you will misclassify legitimate corporate networks and produce a worse failure than the one you set out to fix.

A cached page leaks one country’s pricing to another

Diagnosis: a user reports foreign currency on a page that was correct minutes earlier, and cf-cache-status: HIT appears on the bad response. Reproduce it by fetching the same path twice with different locales and comparing the body hash:

for loc in de en; do
  curl -s "https://www.example.com/pricing" -b "locale=$loc" | md5sum
done
# identical hashes = one cached body serving both markets

Fix: add the locale bucket to the cache key as in Step 4, and mark any response derived from the override cookie Cache-Control: private, no-store. Then purge the poisoned path — until you do, the wrong body keeps being served for the remainder of its TTL regardless of the code fix. A cheap safety net is asserting x-locale against the requested bucket in your synthetic checks, so the next regression is caught by a monitor rather than by a customer.

The geo signal is missing on some requests

Diagnosis: request.cf.country is undefined, or the value is XX (unknown) or T1 (Tor exit). This is normal for a small percentage of traffic and is not a defect. Fix: make sure the resolver treats a missing country as “no opinion” and falls through to Accept-Language and then the default, rather than throwing or emitting an empty locale. Log the rate with x-locale-source: default so you can spot a real regression — a sudden jump usually means the hostname was grey-clouded or a new route bypasses the proxy, not that geolocation itself degraded. Never fail a request because the country is unknown; a page in the default language is always better than an error.

The crawler sees the wrong locale

Diagnosis: search results show German copy on the English URL, or only one locale is indexed at all. Check what the un-steered path actually returns to a bot user agent, and confirm the hreflang set is reciprocal. Fix: exempt crawlers from the redirect branch as in Step 2, make each variant’s canonical self-referential, and ensure every alternate is linked from every other. A second, subtler cause is a cached redirect: if /pricing was cached with a Location header pointing at /en/, every crawler fetch replays it no matter what the code now does. Purge that path and keep no-store on all redirect responses. If your locale gate is implemented alongside country-based access rules, verify the two do not fight — the rules described in blocking or redirecting traffic by country at the edge evaluate before the Worker, so a bot can be blocked before your exemption ever runs.

Frequently Asked Questions

Should I redirect to a localized URL or rewrite the response in place? Redirect when the whole page is translated, so each language earns its own indexable URL, but only from a bare path and only once. Rewrite when the difference is currency, tax, or availability on otherwise identical copy, since a rewrite avoids the extra round trip and keeps shared links working.

Why does an unconditional geo redirect hurt search visibility? A crawler requests your pages from a fixed set of data centers, so an unconditional redirect sends it to one locale every time and the other variants are never fetched. It also turns the bare URL into an alias of a single language, which strips the remaining locales of their entry point. Exempt crawler user agents and let hreflang advertise the alternates instead.

Which signal should win, Accept-Language or the country code? Accept-Language for the language, the country code for the market. The header reflects what the user configured their browser to read, while the IP only says where the network path terminates — an expatriate or a traveler makes those disagree constantly. An explicit cookie override beats both.

Should I use Vary or a custom cache key for locale? Use a bucketed cache key. Vary: Accept-Language is technically correct but stores a separate copy for every distinct header string browsers emit, which collapses your hit ratio. Normalize the signals down to the handful of locales you actually publish and put that value in the key.

What happens to users whose country the edge cannot determine? They arrive with XX, T1, or no country field at all, which the resolver should treat as no opinion and fall through to Accept-Language and then the site default. Log the rate so a sudden increase flags a proxy misconfiguration, but never turn a missing country into an error response.

Back to Geo-Targeted Traffic Routing