Show Cookie Consent Only to EU Visitors Using IP Geolocation (GDPR)
The problem: cookie banners for everyone
GDPR requires cookie consent for EU/EEA visitors. Showing the banner to users in the US, Australia, or Japan is unnecessary — it adds friction for the majority of your users who aren't subject to the regulation. A simple IP-based country check lets you show the banner only where it's legally required.
Legal caveat: IP-based detection is a best-effort mechanism and isn't foolproof (VPNs, corporate proxies). Most GDPR legal guidance accepts it as a reasonable approach for banner triggering, but you should consult your legal team for high-stakes compliance requirements.
EU/EEA country list
// eu-countries.ts
export const EU_EEA_COUNTRIES = new Set([
// EU member states
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR',
'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL',
'PL','PT','RO','SK','SI','ES','SE',
// EEA (non-EU but covered by GDPR)
'IS','LI','NO',
// UK (post-Brexit, still has UK GDPR)
'GB',
// Switzerland (Swiss DPA, similar requirements)
'CH',
]);
Option 1 — Next.js Middleware (recommended for App Router)
Detect the user's country in Edge Middleware and inject it as a cookie or header. The client reads this to decide whether to load the consent SDK.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { EU_EEA_COUNTRIES } from './eu-countries';
export async function middleware(req: NextRequest) {
// 1. Try built-in headers (Vercel / Cloudflare)
let country =
req.headers.get('x-vercel-ip-country') ??
req.headers.get('cf-ipcountry');
// 2. Fall back to API lookup on self-hosted
if (!country) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? req.ip;
if (ip && ip !== '::1' && ip !== '127.0.0.1') {
try {
const r = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
headers: { Authorization: 'Bearer ' + process.env.APOGEOAPI_KEY },
next: { revalidate: 3600 },
});
const d = await r.json();
country = d.country_code;
} catch { /* keep country null */ }
}
}
const requiresConsent = EU_EEA_COUNTRIES.has(country ?? '');
const res = NextResponse.next();
// Set a short-lived cookie the React app can read (not HttpOnly)
res.cookies.set('geo_consent_required', requiresConsent ? '1' : '0', {
maxAge: 3600,
path: '/',
sameSite: 'strict',
});
return res;
}
Option 2 — React hook that reads the cookie
// hooks/useConsentRequired.ts
'use client';
import { useMemo } from 'react';
function getCookie(name: string): string | undefined {
if (typeof document === 'undefined') return undefined;
return document.cookie
.split(';')
.map(c => c.trim().split('='))
.find(([k]) => k === name)?.[1];
}
export function useConsentRequired(): boolean {
return useMemo(() => getCookie('geo_consent_required') === '1', []);
}
// Usage in layout.tsx:
// const consentRequired = useConsentRequired();
// {consentRequired && <CookieBanner />}
Option 3 — Server Component (App Router)
If you prefer reading it in a Server Component, use the cookies() helper from next/headers:
// app/layout.tsx
import { cookies } from 'next/headers';
import { CookieBanner } from '@/components/CookieBanner';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies();
const consentRequired = cookieStore.get('geo_consent_required')?.value === '1';
return (
<html>
<body>
{children}
{consentRequired && <CookieBanner />}
</body>
</html>
);
}
Option 4 — Node.js / Express server-side rendering
// express middleware
import express from 'express';
import { EU_EEA_COUNTRIES } from './eu-countries';
const geo = express.Router();
geo.use(async (req, res, next) => {
const ip = (req.headers['x-forwarded-for'] as string)
?.split(',')[0].trim() ?? req.ip;
let country = 'US'; // default to non-EU
if (ip && ip !== '127.0.0.1' && ip !== '::1') {
try {
const r = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
headers: { Authorization: `Bearer ${process.env.APOGEOAPI_KEY}` },
});
const d = await r.json();
country = d.country_code ?? 'US';
} catch { /* keep default */ }
}
res.locals.consentRequired = EU_EEA_COUNTRIES.has(country);
next();
});
// In your template:
// <% if (consentRequired) { %> <!-- show cookie banner --> <% } %>
Option 5 — Vanilla JS (client-side, lazy)
For simple static sites, call the API client-side and cache in sessionStorage:
const EU_EEA = new Set([
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR',
'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL',
'PL','PT','RO','SK','SI','ES','SE','IS','LI','NO','GB','CH',
]);
async function shouldShowConsentBanner() {
const cached = sessionStorage.getItem('geo_country');
if (cached) return EU_EEA.has(cached);
try {
const r = await fetch('https://api.apogeoapi.com/v1/ip/me', {
headers: { Authorization: 'Bearer YOUR_KEY' },
});
const { country_code } = await r.json();
sessionStorage.setItem('geo_country', country_code);
return EU_EEA.has(country_code);
} catch {
// Fail open — show banner if lookup fails
return true;
}
}
document.addEventListener('DOMContentLoaded', async () => {
if (await shouldShowConsentBanner()) {
document.getElementById('cookie-banner')?.classList.remove('hidden');
}
});
The /v1/ip/me endpoint automatically detects the caller's IP — no IP extraction needed on the client.
Performance tips
- Set a cookie duration: 1 hour is enough. The user's country won't change mid-session.
- Default to showing the banner when the lookup fails — it's better to show an unnecessary banner than to violate GDPR.
- Cache by subnet: on your backend, cache the country result for each /24 subnet to reduce API calls.
- Combine with consent state: once the user accepts or declines, store that choice in localStorage and don't show the banner again regardless of country.
Resources
- ApogeoAPI IP Geolocation — free tier, edge-friendly
- GDPR cookie guidance — official EU summary
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key