GDPRGeolocationComplianceTutorial

Show Cookie Consent Only to EU Visitors Using IP Geolocation (GDPR)

ApogeoAPI5 min read

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

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key