How to Detect Country from IP Address in JavaScript (Browser + Node.js)
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
| Context | Best approach |
|---|---|
| Browser (client-side) | fetch /v1/geo/self with sessionStorage cache |
| React hook | useCountry() with in-memory Map cache |
| Node.js / Express | Module-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 Component | Read 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