Detect a user's country from their IP address in Node.js (free offline database, no API calls)
Showing prices in the right currency, picking a default language, or applying regional rules all start with the same question: which country is this request coming from? The usual answer is a geolocation database, a file that maps IP ranges to locations. With a local copy, a lookup is a fast in-memory operation, with no third-party API to call or rate limit to hit.
This post uses the free DB-IP Lite databases (October 2026 edition) in MMDB format, read with the maxmind npm package (v5.0.7). All code ran on node:24-slim (Node 24.21) and node:26-slim (Node 26.10), with identical results.
Getting a database
Two free databases use the same MMDB format and work with the same code:
- DB-IP Lite: downloadable without an account, updated monthly, licensed under CC BY 4.0. That license requires a visible link, "IP Geolocation by DB-IP", on pages that show the results. There's a country edition (8.3 MB, 4.1 MB gzipped) and a city edition (127 MB, 60 MB gzipped).
- MaxMind GeoLite2: needs a free account and a license key to download, and its license requires you to delete old copies within 30 days of a new release, so you have to automate updates.
The DB-IP file name contains the year and month, so a download script can build it from the date:
curl -sSfL "https://download.db-ip.com/free/dbip-country-lite-$(date -u +%Y-%m).mmdb.gz" \
| gunzip > dbip-country-lite.mmdb-f makes curl fail on an HTTP error instead of saving the error page as your database. Run it from a monthly cron job or in your Docker build.
Looking up a country
npm install maxmindimport maxmind from 'maxmind';
// Loaded once at startup, then every lookup is in memory
const countries = await maxmind.open('./dbip-country-lite.mmdb');
export function countryOf(ip) {
// get() does not validate: "1.2.3" returns a country, so check first
if (!maxmind.validate(ip)) return null;
return countries.get(ip)?.country?.iso_code ?? null;
}import { countryOf } from './country.mjs';
for (const ip of ['8.8.8.8', '1.1.1.1', '81.2.69.142', '2a00:1450:4001:80b::200e', '192.168.1.10', '127.0.0.1', 'not-an-ip']) {
console.log(ip.padEnd(26), countryOf(ip));
}$ node lookup.mjs
8.8.8.8 US
1.1.1.1 AU
81.2.69.142 GB
2a00:1450:4001:80b::200e DE
192.168.1.10 null
127.0.0.1 null
not-an-ip nullIPv4 and IPv6 use the same call. Private and loopback addresses aren't in the database, so they return null. When you test on localhost or inside a Docker network, you'll always get null, and that doesn't mean the code is broken.
Note 1.1.1.1: Cloudflare's DNS resolver is served from data centres around the world, but the database lists the country the range is registered to. A database can only say where an address is registered or usually routed, not where the person is. VPNs, mobile carriers and corporate proxies all move that answer. Use the result as a default the user can change, not as proof of location.
Validate first: get() doesn't
That validate() call matters more than it looks. get() doesn't check its input, and for some malformed strings it returns a confident answer:
"not-an-ip" validate: false get: null
"unknown" validate: false get: null
"1.2.3" validate: false get: "AU"
"8.8.8.8, 10.0.0.1" validate: false get: "US"
"999.1.1.1" validate: false get: nullThe fourth line is a raw X-Forwarded-For header value. Passing a header to get() unparsed "works" and returns the first address's country, which makes this bug easy to miss.
What else is in the record
The country edition returns more than the ISO code: the continent, an EU-membership flag, and names in ten languages:
> countries.get('81.2.69.142')
{
continent: { code: 'EU', geoname_id: 6255148, names: { de: 'Europa', en: 'Europe', … } },
country: {
geoname_id: 2635167,
is_in_european_union: false,
iso_code: 'GB',
names: { de: 'Vereinigtes Königreich Großbritannien und Nordirland', en: 'United Kingdom', fr: 'Royaume-Uni', … }
}
}The city edition adds city, subdivisions (region or state) and location:
import maxmind from 'maxmind';
const cities = await maxmind.open('./dbip-city-lite.mmdb');
const r = cities.get('81.2.69.142');
console.log({
city: r.city?.names.en,
region: r.subdivisions?.[0]?.names.en,
country: r.country?.iso_code,
lat: r.location?.latitude,
lon: r.location?.longitude,
});{
city: 'London',
region: 'England',
country: 'GB',
lat: 51.5143,
lon: -0.0912244
}Speed and memory
maxmind.open() reads the whole file into memory, so the cost is paid once at startup. The test looked up 100,000 random IPv4 addresses:
| Database | File size | open() | Extra RSS | Lookups per second |
|---|---|---|---|---|
| DB-IP Country Lite | 8.3 MB | 24–40 ms | ~37 MB | ~1,000,000 |
| DB-IP City Lite | 127 MB | 162–184 ms | ~185 MB | ~330,000 |
Even the city database is fast enough to run on every request. Memory is the real cost: if you only need the country, use the country edition. Open the database once at module level, as above, and never per request.
Which IP to look up
The lookup is only as good as the IP you give it, and the right one depends on how requests reach your app:
- Direct connection: use
req.socket.remoteAddress, stripping the::ffff:prefix Node adds to IPv4 addresses on a dual-stack socket. IgnoreX-Forwarded-For, because any client can send it. - Behind your own reverse proxy (nginx, a load balancer): take the address that your proxy adds to
X-Forwarded-For, not the first one in the list. Blocking users by IP in Node.js shows how, including Express'strust proxysetting. - Behind a CDN that geolocates for you: you may not need a database at all.
When the CDN already knows: CF-IPCountry
Cloudflare looks up every visitor's location at the edge. Enable the Add visitor location headers Managed Transform and each request to your origin carries a CF-IPCountry header with the two-letter code. Inside Cloudflare Workers the same data is in request.cf.country. Cloudflare uses two codes that aren't real countries: XX (unknown) and T1 (Tor). This server prefers the header when it's behind Cloudflare and falls back to the database:
import http from 'node:http';
import { countryOf } from './country.mjs';
// Set this only when the app really runs behind your own proxy/CDN.
const BEHIND_CLOUDFLARE = process.env.BEHIND_CLOUDFLARE === '1';
function clientCountry(req) {
if (BEHIND_CLOUDFLARE) {
// Cloudflare already looked it up. XX = unknown, T1 = Tor
const cf = req.headers['cf-ipcountry'];
if (cf && cf !== 'XX' && cf !== 'T1') return cf;
return countryOf(req.headers['cf-connecting-ip'] ?? '');
}
// Direct connection: the socket address is the only thing you can trust
return countryOf(req.socket.remoteAddress.replace(/^::ffff:/, ''));
}
http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ country: clientCountry(req) }));
}).listen(3130, () => console.log('listening on 3130'));Requests sent with curl from another container on the same Docker network:
== BEHIND_CLOUDFLARE=0
plain: {"country":null}
X-Forwarded-For: 8.8.8.8: {"country":null}
CF-IPCountry: PL: {"country":null}
CF-IPCountry: XX + CF-Connecting-IP: 81.2.69.142: {"country":null}
== BEHIND_CLOUDFLARE=1
plain: {"country":null}
X-Forwarded-For: 8.8.8.8: {"country":null}
CF-IPCountry: PL: {"country":"PL"}
CF-IPCountry: XX + CF-Connecting-IP: 81.2.69.142: {"country":"GB"}In direct mode, every forged header is ignored, and the private Docker address correctly gives null. With BEHIND_CLOUDFLARE=1, the header wins, and XX falls back to a database lookup of CF-Connecting-IP. But look at what that test actually did: curl, not Cloudflare, sent CF-IPCountry: PL, and the server believed it. Trusting these headers is only safe when your origin accepts traffic exclusively from Cloudflare, through a Cloudflare Tunnel or a firewall that allows only Cloudflare's IP ranges. Otherwise anyone can pick their own country by calling your server directly.
Sources & further reading
- node-maxmind on GitHub (
open(),Reader,validate()) - DB-IP: IP to Country Lite and IP to City Lite (downloads, CC BY 4.0 attribution)
- MaxMind: GeoLite2 free geolocation data (account, license key, 30-day update rule)
- MaxMind DB file format specification
- Cloudflare: HTTP headers (
CF-IPCountry,CF-Connecting-IP,XXandT1) - Cloudflare Workers: request.cf properties
- Cloudflare IP ranges (for locking the origin down to Cloudflare)
- Node.js docs: socket.remoteAddress
IP Geolocation by DB-IP.