IP Geolocation in Cloudflare Workers: Country Detection Without an API Call
Cloudflare's built-in geolocation
Every Cloudflare Worker request includes a cf object on the incoming Request. It contains geolocation data Cloudflare resolves for free — no API key, no extra latency:
export default {
async fetch(request: Request): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
console.log(cf.country); // "US" | "DE" | "BR" | ...
console.log(cf.city); // "New York" (Cloudflare Pro+)
console.log(cf.region); // "New York" (US only, Cloudflare Pro+)
console.log(cf.timezone); // "America/New_York" (Pro+)
console.log(cf.latitude); // "40.71" (Pro+)
console.log(cf.longitude); // "-74.01" (Pro+)
console.log(cf.asOrganization); // ISP/ASN name
return new Response(`Hello from ${cf.country}`);
},
};
Important: cf.country is available on the free Cloudflare plan. cf.city, cf.region, cf.timezone, and cf.latitude/cf.longitude require Cloudflare Pro ($20/mo) or higher.
Geo-based routing in a Worker
// Route users to region-specific backends
const REGIONAL_BACKENDS: Record<string, string> = {
EU: 'https://eu.api.example.com',
AS: 'https://ap.api.example.com',
default: 'https://us.api.example.com',
};
export default {
async fetch(request: Request): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
const continent = cf.continent ?? 'NA'; // NA, EU, AS, SA, OC, AF
const backend = REGIONAL_BACKENDS[continent] ?? REGIONAL_BACKENDS.default;
const url = new URL(request.url);
url.host = new URL(backend).host;
// Forward to the right datacenter
return fetch(new Request(url, request));
},
};
Adding currency data from ApogeoAPI
cf.country gives you the country code, but not the currency. Use ApogeoAPI's countries endpoint to get currency without a full IP lookup:
// wrangler.toml: add [vars] APOGEO_KEY = "..."
interface CountryMeta {
currencyCode: string;
currencySymbol: string;
currencyName: string;
}
const currencyCache = new Map<string, CountryMeta>();
async function getCurrencyForCountry(
countryCode: string,
apiKey: string
): Promise<CountryMeta> {
if (currencyCache.has(countryCode)) {
return currencyCache.get(countryCode)!;
}
const res = await fetch(
`https://api.apogeoapi.com/v1/countries/${countryCode}?apikey=${apiKey}`
);
if (!res.ok) {
return { currencyCode: 'USD', currencySymbol: '$', currencyName: 'US Dollar' };
}
const data = await res.json();
const meta: CountryMeta = {
currencyCode: data.currencyCode,
currencySymbol: data.currencySymbol,
currencyName: data.currencyName,
};
currencyCache.set(countryCode, meta);
return meta;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
const country = cf.country ?? 'US';
const currency = await getCurrencyForCountry(country, env.APOGEO_KEY);
return new Response(JSON.stringify({
country,
...currency,
}), {
headers: { 'Content-Type': 'application/json' },
});
},
};
Localized pricing at the edge
// Full pricing Worker — zero added latency for currency conversion
const PPP: Record<string, number> = {
BR: 0.55, MX: 0.60, IN: 0.35, AR: 0.40, PL: 0.65,
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
const country = cf.country ?? 'US';
// Parallel: country meta + exchange rate
const [countryMeta, rateData] = await Promise.all([
fetch(`https://api.apogeoapi.com/v1/countries/${country}?apikey=${env.APOGEO_KEY}`)
.then(r => r.json()),
fetch(`https://api.apogeoapi.com/v1/rates/USD?apikey=${env.APOGEO_KEY}`)
.then(r => r.json()),
]);
const usdPrice = 29;
const ppp = PPP[country] ?? 1;
const rate = rateData.rates?.[countryMeta.currencyCode] ?? 1;
const localPrice = Math.round(usdPrice * ppp * rate);
return new Response(JSON.stringify({
country,
currency: countryMeta.currencyCode,
symbol: countryMeta.currencySymbol,
price: localPrice,
usdPrice,
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600', // cache 1h at Cloudflare edge
},
});
},
};
Country-gating: block or redirect by region
const BLOCKED_COUNTRIES = new Set(['CN', 'RU', 'KP', 'IR']);
const EU_COUNTRIES = new Set([
'AT','BE','BG','CY','CZ','DE','DK','EE','ES','FI',
'FR','GR','HR','HU','IE','IT','LT','LU','LV','MT',
'NL','PL','PT','RO','SE','SI','SK'
]);
export default {
async fetch(request: Request): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
const country = cf.country ?? 'US';
const url = new URL(request.url);
// Block restricted countries
if (BLOCKED_COUNTRIES.has(country)) {
return new Response('Service not available in your region.', {
status: 451, // "Unavailable For Legal Reasons"
});
}
// Redirect EU users to GDPR-compliant endpoint
if (EU_COUNTRIES.has(country) && !url.pathname.startsWith('/eu/')) {
url.pathname = '/eu' + url.pathname;
return Response.redirect(url.toString(), 302);
}
return fetch(request);
},
};
A/B testing by country
// Show new pricing page to US visitors, old to everyone else
export default {
async fetch(request: Request): Promise<Response> {
const cf = request.cf as IncomingRequestCfProperties;
const country = cf.country ?? 'US';
const url = new URL(request.url);
if (url.pathname === '/pricing' && country === 'US') {
url.pathname = '/pricing-v2';
return fetch(new Request(url, request));
}
return fetch(request);
},
};
When to use cf.country vs. ApogeoAPI
| Need | Use |
|---|---|
| Country code only (routing, blocking, A/B) | cf.country — zero latency, free |
| Country code + city + timezone | cf.city / cf.timezone — requires Cloudflare Pro |
| Currency code + symbol for the country | ApogeoAPI Countries endpoint (cacheable, ~1 request/country ever) |
| Live exchange rate for price conversion | ApogeoAPI Exchange Rates endpoint (cache 1h) |
| IP lookup outside Cloudflare (other CDN, server) | ApogeoAPI Geo endpoint |
For most Cloudflare Workers use cases, cf.country covers the routing/blocking layer for free. ApogeoAPI handles the data layer (currency, exchange rates) that Cloudflare doesn't provide.
Full API docs: api.apogeoapi.com/api/docs. Free tier: app.apogeoapi.com/register.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key