A/B TestingFeature FlagsIP GeolocationTutorial

Geo-Based A/B Testing and Feature Flags: Show Features by Country

ApogeoAPI5 min read

Why geo-based feature flags?

Common use cases:

  • GDPR/CCPA compliance — show cookie consent and data settings only to EU/CA visitors
  • Staged rollouts — launch a new feature in one country first, monitor, then expand
  • Regulatory compliance — hide certain financial products from restricted regions
  • Localized features — show WhatsApp share button in Brazil, LINE in Japan, WeChat in China
  • Pricing experiments — test PPP pricing in Brazil before rolling out globally

Simple country-based flag check

// lib/flags.ts — lightweight geo feature flags without a third-party
const geoCache = new Map<string, { country: string; expires: number }>();

async function getCountry(ip: string): Promise<string> {
  const subnet = ip.split('.').slice(0, 3).join('.') + '.0';
  const cached = geoCache.get(subnet);
  if (cached && cached.expires > Date.now()) return cached.country;

  try {
    const res = await fetch(
      `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
      { signal: AbortSignal.timeout(2000) }
    );
    const { countryCode } = await res.json();
    geoCache.set(subnet, { country: countryCode, expires: Date.now() + 3_600_000 });
    return countryCode;
  } catch {
    return 'US'; // fallback
  }
}

// Flag definitions
const FLAGS = {
  COOKIE_CONSENT:     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','GB']),
  WHATSAPP_SHARE:     new Set(['BR','IN','MX','ID','PH','NG','PK','EG','ZA']),
  LINE_SHARE:         new Set(['JP','TH','TW']),
  PPP_PRICING:        new Set(['BR','IN','MX','AR','PL','TR','ZA']),
  CRYPTO_PAYMENTS:    new Set(['US','CA','GB','DE','AU','SG']),
  PHONE_REQUIRED:     new Set(['CN']),
};

export async function getFlags(ip: string) {
  const country = await getCountry(ip === '127.0.0.1' ? 'US' : ip);

  return {
    country,
    cookieConsent:    FLAGS.COOKIE_CONSENT.has(country),
    whatsappShare:    FLAGS.WHATSAPP_SHARE.has(country),
    lineShare:        FLAGS.LINE_SHARE.has(country),
    pppPricing:       FLAGS.PPP_PRICING.has(country),
    cryptoPayments:   FLAGS.CRYPTO_PAYMENTS.has(country),
    phoneRequired:    FLAGS.PHONE_REQUIRED.has(country),
  };
}

Use in Next.js layout (server-side)

// app/layout.tsx
import { headers } from 'next/headers';
import { getFlags } from '@/lib/flags';
import { CookieBanner } from '@/components/CookieBanner';

export default async function RootLayout({ children }) {
  const headersList = headers();
  const ip = headersList.get('x-forwarded-for')?.split(',')[0] ?? '127.0.0.1';
  const flags = await getFlags(ip);

  return (
    <html>
      <body>
        {children}
        {/* Only show cookie consent in EU/UK */}
        {flags.cookieConsent && <CookieBanner />}
      </body>
    </html>
  );
}

Expose flags via API route for client components

// app/api/flags/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getFlags } from '@/lib/flags';

export async function GET(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]
    ?? req.ip
    ?? '127.0.0.1';

  const flags = await getFlags(ip);

  return NextResponse.json(flags, {
    headers: {
      'Cache-Control': 'private, max-age=3600',
    },
  });
}

// Client-side hook
// function useFlags() {
//   const [flags, setFlags] = useState(null);
//   useEffect(() => { fetch('/api/flags').then(r => r.json()).then(setFlags); }, []);
//   return flags;
// }

Staged rollout by continent

// Roll out a new checkout flow: EU first, then US, then global
const CHECKOUT_V2_COUNTRIES: Set<string> = (() => {
  // Phase 1 (2026-05-01): EU only
  // Phase 2 (2026-05-15): + US, CA
  // Phase 3 (2026-06-01): global
  const today = new Date();
  if (today >= new Date('2026-06-01')) {
    return new Set(['*']); // special: wildcard = all countries
  }
  if (today >= new Date('2026-05-15')) {
    return new Set(['AT','BE','DE','ES','FR','GB','IT','NL','US','CA']);
  }
  return new Set(['AT','BE','DE','ES','FR','GB','IT','NL']);
})();

function isCheckoutV2Enabled(country: string): boolean {
  return CHECKOUT_V2_COUNTRIES.has('*') || CHECKOUT_V2_COUNTRIES.has(country);
}

GDPR compliance: conditional analytics

// Only load analytics scripts after consent for EU/UK visitors
// For non-EU: load directly (no consent required by GDPR)

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','GB','NO','IS','LI']);  // GDPR scope

async function loadAnalytics(ip: string) {
  const country = await getCountry(ip);
  const needsConsent = EU_COUNTRIES.has(country);

  if (!needsConsent) {
    // Non-EU: load analytics immediately
    loadGoogleAnalytics();
    loadMixpanel();
    return;
  }

  // EU: wait for cookie consent
  document.addEventListener('cookieConsentGranted', () => {
    loadGoogleAnalytics();
    loadMixpanel();
  });
}

// Dispatch from cookie consent banner:
// document.dispatchEvent(new Event('cookieConsentGranted'));

Performance tips

  • Cache flags in a cookie or sessionStorage — don't re-fetch on every page
  • Server-side is faster for first paint — read flags in Next.js server components so the correct UI renders immediately without a flash
  • Use continent codes for broad groupings: cf.continent on Cloudflare gives AF, AS, EU, NA, OC, SA without any API call
  • Don't block rendering — use a skeleton/default state and resolve flags in the background for non-critical UI elements

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