Cloudflare WorkersEdge ComputingIP GeolocationTutorial

IP Geolocation in Cloudflare Workers: Country Detection Without an API Call

ApogeoAPI5 min read

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