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.continenton 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