JavaScriptIP GeolocationCountry DetectionTutorial

How to Detect Country from IP Address in JavaScript (Browser + Node.js)

ApogeoAPI7 min read

Why detect country from IP in JavaScript?

Country detection from IP enables three common use cases:

  • Localized pricing — show prices in local currency
  • Geo-restricted content — block or gate content by region
  • Language auto-detection — pre-select the UI language

This guide covers every JavaScript context: browser fetch(), Node.js, Next.js 14 Middleware, and a reusable React hook.

The API call

All patterns below use ApogeoAPI's IP geolocation endpoint:

GET https://api.apogeoapi.com/v1/geo/{ip}?apikey=YOUR_KEY

# Response
{
  "ip": "8.8.8.8",
  "countryCode": "US",
  "countryName": "United States",
  "regionName": "California",
  "city": "Mountain View",
  "latitude": 37.386,
  "longitude": -122.0838,
  "timezone": "America/Los_Angeles",
  "currencyCode": "USD",
  "currencySymbol": "$"
}

Get a free API key at app.apogeoapi.com/register (1,000 requests/month free).

1. Browser — fetch visitor's own country

When the endpoint receives no IP, it geolocates the caller's own IP:

// Detect the current visitor's country in the browser
async function getVisitorCountry() {
  const res = await fetch(
    'https://api.apogeoapi.com/v1/geo/self?apikey=YOUR_KEY'
  );
  const data = await res.json();
  return data.countryCode; // "US", "DE", "BR", etc.
}

// Usage
getVisitorCountry().then(country => {
  console.log('Visitor is in:', country);
  document.getElementById('flag').textContent =
    country === 'US' ? '🇺🇸' : country === 'DE' ? '🇩🇪' : '🌍';
});

2. Browser — with caching (sessionStorage)

Avoid making a new request on every page load:

async function getCountryCached() {
  const CACHE_KEY = 'visitor_country';
  const cached = sessionStorage.getItem(CACHE_KEY);
  if (cached) return cached;

  const res = await fetch(
    'https://api.apogeoapi.com/v1/geo/self?apikey=YOUR_KEY'
  );
  const { countryCode } = await res.json();
  sessionStorage.setItem(CACHE_KEY, countryCode);
  return countryCode;
}

// Show localized price
getCountryCached().then(country => {
  const prices = { US: '$29', GB: '£23', DE: '€27', default: '$29' };
  document.getElementById('price').textContent =
    prices[country] ?? prices.default;
});

3. React hook — useCountry()

// hooks/useCountry.ts
import { useState, useEffect } from 'react';

interface GeoData {
  countryCode: string;
  countryName: string;
  currencyCode: string;
  currencySymbol: string;
  city: string;
}

const cache = new Map<string, GeoData>();

export function useCountry(): { geo: GeoData | null; loading: boolean } {
  const [geo, setGeo] = useState<GeoData | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const cacheKey = 'self';
    if (cache.has(cacheKey)) {
      setGeo(cache.get(cacheKey)!);
      setLoading(false);
      return;
    }

    fetch(`https://api.apogeoapi.com/v1/geo/self?apikey=${process.env.NEXT_PUBLIC_APOGEO_KEY}`)
      .then(r => r.json())
      .then(data => {
        cache.set(cacheKey, data);
        setGeo(data);
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, []);

  return { geo, loading };
}

// Component usage
export function PriceCard() {
  const { geo, loading } = useCountry();

  if (loading) return <div>Loading...</div>;

  const symbol = geo?.currencySymbol ?? '$';
  const price = geo?.countryCode === 'GB' ? 23 : 29;

  return (
    <div>
      <span>{symbol}{price}/month</span>
      <span className="text-sm text-gray-500">
        Prices shown in {geo?.currencyCode ?? 'USD'}
      </span>
    </div>
  );
}

4. Node.js — detect country from any IP

// geo.js — reusable module with /24 subnet caching
const cache = new Map();

export async function getCountryFromIP(ip) {
  // Cache at /24 subnet level: 8.8.8.x all share one lookup
  const subnet = ip.split('.').slice(0, 3).join('.') + '.0';

  if (cache.has(subnet)) {
    return cache.get(subnet);
  }

  const res = await fetch(
    `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`
  );

  if (!res.ok) {
    // Fallback: assume US or use Accept-Language header
    return { countryCode: 'US', currencyCode: 'USD' };
  }

  const data = await res.json();
  cache.set(subnet, data);

  // Auto-expire after 1 hour
  setTimeout(() => cache.delete(subnet), 3_600_000);

  return data;
}

// Express usage
app.get('/pricing', async (req, res) => {
  const ip = req.headers['x-forwarded-for']?.split(',')[0] ?? req.ip;
  const { countryCode, currencyCode } = await getCountryFromIP(ip);

  const usdPrice = 29;
  // Use ApogeoAPI's exchange rate endpoint for live conversion
  res.json({
    country: countryCode,
    currency: currencyCode,
    price: usdPrice,
    label: `${currencyCode} ${usdPrice}`,
  });
});

5. Next.js 14 — Edge Middleware (fastest approach)

Next.js Middleware runs at the edge before the page renders. Vercel and Cloudflare inject the country in a header so you don't even need an API call:

// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Vercel injects x-vercel-ip-country automatically
  const country = request.headers.get('x-vercel-ip-country')
    ?? request.geo?.country
    ?? 'US';

  const response = NextResponse.next();
  response.headers.set('x-country', country);
  return response;
}

export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)'],
};

If you're not on Vercel, use the API in middleware instead:

// middleware.ts — self-hosted or non-Vercel
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? '127.0.0.1';

  try {
    const geo = await fetch(
      `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
      { next: { revalidate: 3600 } } // cache 1h at CDN level
    ).then(r => r.json());

    const response = NextResponse.next();
    response.cookies.set('country', geo.countryCode, { maxAge: 3600 });
    return response;
  } catch {
    return NextResponse.next();
  }
}

6. Read country in a Server Component

// app/pricing/page.tsx
import { cookies, headers } from 'next/headers';

export default async function PricingPage() {
  // Option A: from middleware cookie
  const country = cookies().get('country')?.value ?? 'US';

  // Option B: from middleware header
  // const country = headers().get('x-country') ?? 'US';

  const prices: Record<string, { amount: number; currency: string }> = {
    US: { amount: 29, currency: 'USD' },
    GB: { amount: 23, currency: 'GBP' },
    DE: { amount: 27, currency: 'EUR' },
    BR: { amount: 149, currency: 'BRL' },
  };

  const { amount, currency } = prices[country] ?? prices.US;

  return (
    <main>
      <h1>Pricing</h1>
      <p>
        {currency} {amount}/month — tailored for {country}
      </p>
    </main>
  );
}

Handling edge cases

Localhost / development

In development, req.ip is 127.0.0.1 or ::1. Handle this explicitly:

const ip = req.ip === '::1' || req.ip === '127.0.0.1'
  ? '8.8.8.8'  // test with a real IP in dev
  : req.ip;

IPv6

ApogeoAPI supports full IPv6. If you're stripping the last octet for caching, adapt the subnet key:

function subnetKey(ip) {
  if (ip.includes(':')) {
    // IPv6 — use first 4 groups as subnet
    return ip.split(':').slice(0, 4).join(':');
  }
  // IPv4 — use first 3 octets
  return ip.split('.').slice(0, 3).join('.') + '.0';
}

Rate limits and fallback

async function getGeoSafe(ip) {
  try {
    const res = await fetch(
      `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
      { signal: AbortSignal.timeout(2000) } // 2s timeout
    );
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    console.warn('[geo] lookup failed, using fallback:', err.message);
    return { countryCode: 'US', currencyCode: 'USD', currencySymbol: '$' };
  }
}

Free tier and quotas

With /24 subnet caching, 1,000 free monthly requests go a long way:

  • A typical office has 1 unique /24 subnet — all 200 employees share 1 cached lookup
  • An e-commerce site with 10,000 unique visitors/day from 20 countries may only see ~500 unique subnets/day
  • The free tier covers most hobbyist and small SaaS apps without a paid plan

Summary

ContextBest approach
Browser (client-side)fetch /v1/geo/self with sessionStorage cache
React hookuseCountry() with in-memory Map cache
Node.js / ExpressModule-level Map with /24 subnet key
Next.js (Vercel)Read x-vercel-ip-country header — no API call needed
Next.js (self-hosted)Middleware fetches API, sets country cookie
Server ComponentRead country from cookie or header set by middleware

Full API docs at api.apogeoapi.com/api/docs. Free tier signup at 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