Integrations · Express
IPAPI.GR for Express
A small middleware adds req.geo to every request: country, region, city, timezone, ASN and ISP, plus the risk flags on paid plans. Answers are cached in memory, lookups time out after 1.5 seconds, and errors never block a visitor.
- Written for
- Express 4 and 5, Node.js 18+
- Runs in
- App middleware
- Client IP from
- req.ip with trust proxy
01 · Setup
3 steps, copy and paste.
Every lookup runs on your server or edge, so the key never reaches a browser. Each example times out quickly, caches answers per IP and lets the visitor through if the lookup fails.
-
Set the key
Read it from the environment so it stays out of source control. Node.js 18 and later have fetch built in, so there is nothing to install.
ShellShell # Keep the key out of source control export IPAPI_KEY="your_api_key_here" node app.js
-
Add the middleware
It skips private and loopback addresses, caches up to 10.000 answers for an hour, and converts "No Data" to null.
ipapi.jsJavaScript // ipapi.js: Express middleware that adds req.geo for the visitor's IP. // Node.js 18+ (global fetch). Set IPAPI_KEY in the environment. const { BlockList, isIP } = require('node:net'); const LOOKUP = 'https://ipapi.gr/api/lookup/'; const TTL_MS = 60 * 60 * 1000; // keep answers for an hour const MAX_ENTRIES = 10000; const cache = new Map(); // Private, loopback and link-local addresses have no public location: skip them. const local = new BlockList(); for (const [net, bits] of [['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['127.0.0.0', 8], ['169.254.0.0', 16], ['100.64.0.0', 10]]) local.addSubnet(net, bits, 'ipv4'); for (const [net, bits] of [['::1', 128], ['fc00::', 7], ['fe80::', 10]]) local.addSubnet(net, bits, 'ipv6'); // Missing values come back as the string "No Data". const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); async function lookup(ip) { const hit = cache.get(ip); if (hit && hit.expires > Date.now()) return hit.data; const url = LOOKUP + encodeURIComponent(ip) + '?key=' + encodeURIComponent(process.env.IPAPI_KEY); const res = await fetch(url, { signal: AbortSignal.timeout(1500) }); if (!res.ok) return null; // 4xx/5xx: carry on without data const body = await res.json(); const data = { ip: body.ip, country: known(body.country), countryCode: known(body.country_code), region: known(body.region), city: known(body.city), timezone: known(body.timezone), asn: known(body.asn), isp: known(body.isp), risk: body.risk || null // { is_ai, is_crawler, is_scanner } on paid plans only }; if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value); // drop the oldest cache.set(ip, { data, expires: Date.now() + TTL_MS }); return data; } function ipapiGeo() { return async (req, res, next) => { const ip = String(req.ip || '').replace(/^::ffff:/, ''); // IPv4-mapped IPv6 to plain IPv4 const family = isIP(ip); req.geo = null; if (family && !local.check(ip, family === 6 ? 'ipv6' : 'ipv4')) { try { req.geo = await lookup(ip); } catch { // Timeout or network error: fail open, never block a visitor because of a lookup. } } next(); }; } module.exports = { ipapiGeo, lookup }; -
Use it in your app
Set trust proxy to match your setup so req.ip is the visitor and not your load balancer. The second middleware shows the risk-flag pattern for paid plans.
app.jsJavaScript // app.js const express = require('express'); const { ipapiGeo } = require('./ipapi'); const app = express(); app.set('trust proxy', 1); // exactly one reverse proxy (e.g. nginx) in front of the app app.use(ipapiGeo()); // Paid plans: refuse requests that claim to be an AI crawler but don't come from a known AI crawler address. const AI_CLAIM = /GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|Claude-User|PerplexityBot|Perplexity-User/i; app.use((req, res, next) => { const risk = req.geo && req.geo.risk; if (risk && AI_CLAIM.test(req.get('user-agent') || '') && !risk.is_ai) { return res.status(403).send('Unverified crawler'); } next(); }); app.get('/', (req, res) => { res.json({ country: req.geo ? req.geo.countryCode : null, timezone: req.geo ? req.geo.timezone : null }); }); app.listen(process.env.PORT || 3000);
02 · The response
What you get back.
Free keys return location and network data: country, country_code, region, city, zipcode, latitude, longitude, timezone, isp, organization and asn.
Keys on any paid plan add risk.is_ai, risk.is_crawler and risk.is_scanner, which tell you when an address belongs to a known AI crawler, search crawler or internet scanner. The examples use is_ai to refuse requests whose user agent claims an AI crawler while the address isn't one. false means "not a known bot", not "human".
All response fields · AI crawlers we recognise · Bot signal coverage
03 · Pitfalls
What usually goes wrong.
- trust proxy decides what req.ip is
- Without it, behind nginx or a load balancer, every request looks like it comes from the proxy. Set it to the number of proxies in front of you, not to true, or clients can spoof X-Forwarded-For.
- One cache per process
- In cluster mode each worker keeps its own cache. That is fine for saving requests; use a shared store if you run many workers.
- Localhost has no location
- In development the client is 127.0.0.1 or ::1. The snippets skip private and loopback addresses instead of spending a request on them.
- Website restrictions block server calls
- A key restricted to "Allowed websites" needs an Origin or Referer header, which server-side code does not send, so the call is refused. For a server key, leave websites empty and restrict by your server's outgoing IP address instead.
- "No Data" is a string, not null
- When no value is known for a field, the field is still present and set to "No Data". Every snippet converts it to null before you use it.
04 · Questions
Good to know.
Does the lookup slow down every request?
Only the first request from each IP waits for the API, and never longer than the 1.5-second timeout. Later requests from the same IP are answered from the in-memory cache.
Can I use it on selected routes only?
Yes. Mount ipapiGeo() on a router or a single route instead of app.use, for example only on sign-up or checkout.
Do I need a paid plan?
No. Location and network fields work with a free key. req.geo.risk is null on free keys, so the crawler check is skipped.
From the blog
The concepts behind it.
Other integrations
Ship it today.
Free: 1.000 requests a day for location and network data. Bot flags and higher limits on every paid plan, from 9,00 € a month (VAT included).