Why Geographic Routing Belongs at the Edge

Personalizing content based on geographic location is one of the most common needs in global web applications: display prices in the local currency, comply with country-specific regulations (GDPR in Europe, CCPA in California), block content in certain jurisdictions, and redirect towards regional domains.

Historically this was handled with server-side geolocation databases (MaxMind GeoLite2) or with manually configured CDN rules. Both approaches they have limitations: the database server adds latency, the CDN rules are static and difficult to update dynamically.

With Cloudflare Workers, geolocation is already available in the object request.cf without any database to maintain. Cloudflare determines the location based on the network's BGP topology, not on IP lookup, with country-level accuracy above 99.9%.

What You Will Learn

  • Properties available in request.cf for geolocation
  • Geo-fencing: blocking and redirecting by country
  • Localized pricing: Currency and VAT based on region
  • GDPR compliance: automatic cookie consent for EU users
  • Multi-region routing with custom headers
  • Testing geo-based logic without deployment

The Object request.cf by Cloudflare

Each request to a Cloudflare Worker includes the subject cf with Geographic and network metadata determined by Cloudflare in real time:

// Tutte le proprieta disponibili in request.cf

export default {
  async fetch(request: Request): Promise<Response> {
    const cf = request.cf as CfProperties;

    // Geolocalizzazione
    const country = cf.country;         // "IT" - ISO 3166-1 alpha-2
    const region = cf.region;           // "Puglia" - nome della regione
    const regionCode = cf.regionCode;   // "75" - codice regione
    const city = cf.city;               // "Bari"
    const postalCode = cf.postalCode;   // "70121"
    const latitude = cf.latitude;       // "41.1171"
    const longitude = cf.longitude;     // "16.8719"
    const timezone = cf.timezone;       // "Europe/Rome"
    const continent = cf.continent;     // "EU"

    // Rete
    const asn = cf.asn;                 // 1234 - Autonomous System Number
    const asOrganization = cf.asOrganization; // "Telecom Italia"
    const isEuCountry = cf.isEUCountry;       // "1" o "0"

    // Performance
    const colo = cf.colo;               // "FCO" - datacenter Cloudflare piu vicino
    const httpProtocol = cf.httpProtocol; // "HTTP/2"
    const tlsVersion = cf.tlsVersion;    // "TLSv1.3"

    return Response.json({
      country,
      region,
      city,
      timezone,
      continent,
      isEuCountry,
      colo,
    });
  },
};

// Tipo per le proprieta cf (parziale)
interface CfProperties {
  country?: string;
  region?: string;
  regionCode?: string;
  city?: string;
  postalCode?: string;
  latitude?: string;
  longitude?: string;
  timezone?: string;
  continent?: string;
  asn?: number;
  asOrganization?: string;
  isEUCountry?: string;
  colo?: string;
  httpProtocol?: string;
  tlsVersion?: string;
}

Geo-Fencing: Blocking by country

Geo-fencing is the pattern of blocking or redirecting content to specific countries. The most common use cases are: blocking for international sanctions, content with territorial licenses (streaming, media), and markets not yet open to certain products:

// src/geo-fence-worker.ts

// Paesi con accesso bloccato (esempio: sanzioni, licenze)
const BLOCKED_COUNTRIES = new Set(['KP', 'IR', 'SY', 'CU']);

// Paesi che richiedono un redirect a una versione localizzata
const REDIRECTS: Record<string, string> = {
  DE: 'https://de.example.com',
  FR: 'https://fr.example.com',
  JP: 'https://jp.example.com',
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as CfProperties;
    const country = cf.country ?? 'US';
    const url = new URL(request.url);

    // Blocco per paesi non consentiti
    if (BLOCKED_COUNTRIES.has(country)) {
      return new Response(
        JSON.stringify({
          error: 'Service not available in your region',
          country,
        }),
        {
          status: 451, // 451 Unavailable For Legal Reasons
          headers: {
            'Content-Type': 'application/json',
            'Vary': 'CF-IPCountry',
          },
        }
      );
    }

    // Redirect verso versione localizzata per certi paesi
    const redirectTarget = REDIRECTS[country];
    if (redirectTarget && !url.pathname.startsWith('/api/')) {
      const targetUrl = new URL(url.pathname + url.search, redirectTarget);
      return Response.redirect(targetUrl.toString(), 302);
    }

    // Aggiunge header con il paese per il downstream (server di origine)
    const headers = new Headers(request.headers);
    headers.set('CF-Worker-Country', country);
    headers.set('CF-Worker-Continent', cf.continent ?? '');
    headers.set('CF-Worker-Timezone', cf.timezone ?? '');

    // Prosegui verso il server di origine
    return fetch(new Request(request.url, {
      method: request.method,
      headers,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    }));
  },
};

interface CfProperties {
  country?: string;
  continent?: string;
  timezone?: string;
}

interface Env {}

Localized Prices and Currencies

Showing prices in the customer's local currency is a best practice of e-commerce that increases the conversion rate. With the Worker, you can determine the correct currency before the request even reaches the origin server:

// src/pricing-worker.ts - prezzi localizzati all'edge

interface CurrencyConfig {
  code: string;
  symbol: string;
  position: 'before' | 'after';
  vatRate: number; // IVA in percentuale (0.22 = 22%)
}

const COUNTRY_CURRENCY: Record<string, CurrencyConfig> = {
  // Eurozona
  IT: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.22 },
  DE: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.19 },
  FR: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.20 },
  ES: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.21 },
  // Altre valute
  GB: { code: 'GBP', symbol: '£', position: 'before', vatRate: 0.20 },
  US: { code: 'USD', symbol: ', position: 'before', vatRate: 0 },
  JP: { code: 'JPY', symbol: '¥', position: 'before', vatRate: 0.10 },
  CH: { code: 'CHF', symbol: 'CHF', position: 'after', vatRate: 0.081 },
  DEFAULT: { code: 'USD', symbol: ', position: 'before', vatRate: 0 },
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string };
    const country = cf.country ?? 'US';
    const url = new URL(request.url);

    // Solo per le route del catalogo prodotti
    if (!url.pathname.startsWith('/api/products')) {
      return fetch(request);
    }

    // Recupera i prodotti dal server di origine (prezzi in USD base)
    const originResponse = await fetch(request);
    if (!originResponse.ok || !originResponse.headers.get('Content-Type')?.includes('json')) {
      return originResponse;
    }

    const products = await originResponse.json() as Product[];

    // Recupera il tasso di cambio (con cache in KV per 1 ora)
    const currency = COUNTRY_CURRENCY[country] ?? COUNTRY_CURRENCY['DEFAULT'];
    const exchangeRate = await getExchangeRate(currency.code, env);

    // Trasforma i prezzi
    const localizedProducts = products.map((product) => ({
      ...product,
      pricing: {
        baseUsd: product.priceUsd,
        local: {
          amount: convertPrice(product.priceUsd, exchangeRate),
          currency: currency.code,
          symbol: currency.symbol,
          vatIncluded: currency.vatRate > 0,
          vatRate: currency.vatRate,
          formatted: formatPrice(
            convertPrice(product.priceUsd, exchangeRate) * (1 + currency.vatRate),
            currency
          ),
        },
      },
    }));

    return Response.json(localizedProducts, {
      headers: {
        'Cache-Control': 'private, max-age=300',
        'Vary': 'CF-IPCountry',
        'X-Currency': currency.code,
        'X-Country': country,
      },
    });
  },
};

async function getExchangeRate(currency: string, env: Env): Promise<number> {
  if (currency === 'USD') return 1;

  // Prova dalla cache KV (evita richieste API continue)
  const cacheKey = `fx:${currency}`;
  const cached = await env.RATES_KV.get(cacheKey);
  if (cached) return parseFloat(cached);

  // Fallback: chiama un API di exchange rates
  const response = await fetch(
    `https://api.exchangerate.host/latest?base=USD&symbols=${currency}`,
    { cf: { cacheTtl: 3600 } } as RequestInit
  );
  const data = await response.json() as { rates: Record<string, number> };
  const rate = data.rates[currency] ?? 1;

  // Cache per 1 ora
  await env.RATES_KV.put(cacheKey, String(rate), { expirationTtl: 3600 });

  return rate;
}

function convertPrice(usd: number, rate: number): number {
  return Math.round(usd * rate * 100) / 100;
}

function formatPrice(amount: number, currency: CurrencyConfig): string {
  const formatted = new Intl.NumberFormat('en-US', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(amount);

  return currency.position === 'before'
    ? `${currency.symbol}${formatted}`
    : `${formatted} ${currency.symbol}`;
}

interface Product {
  id: string;
  name: string;
  priceUsd: number;
}

interface Env {
  RATES_KV: KVNamespace;
}

GDPR Compliance at the Edge

The GDPR applies to all users resident in the European Union, regardless of where the server is located. The most common approach is to show the cookie consent banner to EU users and not to others. With the Worker, this logic can be handled before rendering:

// src/gdpr-worker.ts - compliance GDPR all'edge

// Paesi UE + SEE che richiedono GDPR compliance
const GDPR_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',
  // SEE
  'IS', 'LI', 'NO',
  // UK post-Brexit mantiene UK-GDPR
  'GB',
]);

const CONSENT_COOKIE = 'gdpr_consent';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string; isEUCountry?: string };
    const country = cf.country ?? '';

    // Determina se l'utente e soggetto a GDPR
    // Usa sia il country code che il flag isEUCountry di Cloudflare
    const requiresGdpr = GDPR_COUNTRIES.has(country) || cf.isEUCountry === '1';

    // Controlla se l'utente ha gia dato il consenso
    const hasConsent = request.headers.get('Cookie')
      ?.split(';')
      .some((c) => c.trim().startsWith(`${CONSENT_COOKIE}=accepted`));

    // Aggiunge header per comunicare lo stato GDPR al server di origine
    // e ai server components Next.js
    const headers = new Headers(request.headers);
    headers.set('X-GDPR-Required', requiresGdpr ? '1' : '0');
    headers.set('X-GDPR-Consent', hasConsent ? 'accepted' : 'pending');
    headers.set('X-User-Country', country);

    // Per route API analytics: blocca tracking se non c'e consenso
    if (request.url.includes('/api/analytics') && requiresGdpr && !hasConsent) {
      return Response.json(
        { tracked: false, reason: 'consent_required' },
        { status: 200 } // Non e un errore, semplicemente non tracciamo
      );
    }

    // Rewrite per servire il banner GDPR nelle pagine HTML
    // Il server component legge l'header X-GDPR-Required per mostrare/nascondere il banner
    const response = await fetch(new Request(request.url, {
      method: request.method,
      headers,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    }));

    // Clona la risposta aggiungendo header utili per il caching
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('Vary', 'CF-IPCountry'); // Varia la cache per paese
    if (requiresGdpr) {
      // Non cachare le risposte per utenti EU senza consenso esplicito
      if (!hasConsent) {
        newResponse.headers.set('Cache-Control', 'private, no-store');
      }
    }

    return newResponse;
  },
};

interface Env {}

Multi-Region Routing with Custom Headers

An advanced pattern is to use the Worker as a geographic load balancer intelligently, routing requests to the nearest data center based on measured latency and business rules:

// src/geo-router-worker.ts - routing multi-regione

interface RegionConfig {
  origin: string;
  countries: string[];
  fallback?: string;
}

const REGIONS: RegionConfig[] = [
  {
    origin: 'https://api-eu.example.com',
    countries: ['IT', 'DE', 'FR', 'ES', 'PT', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'DK'],
  },
  {
    origin: 'https://api-us-east.example.com',
    countries: ['US', 'CA', 'MX', 'BR', 'AR'],
  },
  {
    origin: 'https://api-apac.example.com',
    countries: ['JP', 'KR', 'SG', 'AU', 'NZ', 'IN', 'TH', 'PH'],
  },
];

const DEFAULT_ORIGIN = 'https://api.example.com';

function selectOrigin(country: string): string {
  for (const region of REGIONS) {
    if (region.countries.includes(country)) {
      return region.origin;
    }
  }
  return DEFAULT_ORIGIN;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string; colo?: string; continent?: string };
    const country = cf.country ?? 'US';

    const origin = selectOrigin(country);
    const url = new URL(request.url);
    const targetUrl = new URL(url.pathname + url.search, origin);

    // Aggiunge header per audit e debug
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('X-Forwarded-Country', country);
    requestHeaders.set('X-Edge-Colo', cf.colo ?? '');
    requestHeaders.set('X-Selected-Origin', origin);

    // Rimuove l'host originale e imposta quello del backend
    requestHeaders.delete('host');

    const response = await fetch(targetUrl.toString(), {
      method: request.method,
      headers: requestHeaders,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    });

    // Aggiunge header diagnostici alla risposta
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('X-Routed-To', origin);
    newResponse.headers.set('X-User-Country', country);

    return newResponse;
  },
};

interface Env {}

Geo-Based Logic Test

Testing geographic routing locally requires simulating the object request.cf. With wrangler dev it is possible inject custom CF values:

# Test locale con override del paese
wrangler dev --test-scheduled

# Nella richiesta HTTP, simula un paese specifico:
# Con wrangler dev, il cf object e simulato ma puoi sovrascrivere
# usando header custom e modificando il handler per development

# Oppure usa curl con header per test:
curl -H "CF-Connecting-IP: 151.28.0.1" http://localhost:8787/api/pricing

# Per test automatizzati con Vitest + Miniflare:
# Imposta cf nella request di test
// test/geo-routing.test.ts - test con Miniflare
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';

describe('Geo-fencing', () => {
  it('should block requests from blocked countries', async () => {
    const request = new Request('https://example.com/api/data', {
      // Simula una richiesta dall'Iran
      cf: { country: 'IR' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.status).toBe(451);

    const body = await response.json() as { error: string };
    expect(body.error).toContain('not available');
  });

  it('should add GDPR header for EU users', async () => {
    const request = new Request('https://example.com/', {
      cf: { country: 'IT', isEUCountry: '1' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.headers.get('X-GDPR-Required')).toBe('1');
  });

  it('should not require GDPR for US users', async () => {
    const request = new Request('https://example.com/', {
      cf: { country: 'US', isEUCountry: '0' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.headers.get('X-GDPR-Required')).toBe('0');
  });
});

Accuracy of Geolocation

Cloudflare uses BGP data for geolocation, which is very accurate at the country level (>99.9%) but less so at the city or region level. For legal use cases (e.g. blocking for sanctions) it is advisable combine Cloudflare geolocation with additional verification (e.g. MaxMind GeoIP on the origin server). Don't use geolocation alone edge as the sole compliance measure for critical legal obligations.

Conclusions and Next Steps

Geographic routing at the edge with Cloudflare Workers radically simplifies customization for global markets: no geolocation database needed, There is no need to change the origin server, and the logic runs anymore close to the user with minimal latency.

The patterns shown — geo-fencing, localized pricing, GDPR compliance, multi-region routing — covers most needs of an international application. The key is to leverage the data you already have present in request.cf instead of adding additional middleware.

Next Articles in the Series

  • Article 8: Cache API and Invalidation Strategies in Cloudflare Workers: How to build a distributed caching layer with TTL, stale-while-revalidate and per-key invalidation to optimize overall performance.
  • Article 9: Testing of Workers in Local — Miniflare, Vitest and Wrangler Dev: the complete workflow for testing geo-based logic and bindings without deployment on Cloudflare.
  • Article 10: Full-Stack Architectures at the Edge — Case Study From Zero to Production: Integrating all concepts from the series into a REST API complete.