Implementing IP Geolocation in Node.js: A Practical Guide

How to add IP geolocation to a Node.js application — from one-line HTTP calls to Express middleware, edge functions, and TypeScript SDKs — with real code you can ship.

Implementing IP Geolocation in Node.js: A Practical Guide

Adding IP geolocation to a Node.js application sounds like a one-line task. In practice you’ll deal with: extracting the client IP from a proxied request, handling IPv4 + IPv6, caching responses, error handling, edge functions vs server runtimes, and TypeScript types if you care about them. This post walks through every layer with code you can paste into a real project.

We’ll use the Ip2Geo API and SDK throughout, but the patterns apply to any IP geolocation provider with a REST API.

The Minimum Viable Implementation

The fastest way to add geolocation to anything in Node.js:

const response = await fetch(`https://api.ip2geo.dev/convert?ip=8.8.8.8`, {
    headers: { 'X-Api-Key': process.env.IP2GEO_API_KEY }
})
const { data } = await response.json()
console.log(data.continent.country.name)  // "United States"

That’s it. Three lines, no SDK, no dependencies (Node 18+ has native fetch). Good for prototyping or a script. Production code needs more — error handling, IP extraction from headers, caching — so let’s build it up.

Using the Official SDK

npm install @ip2geo/sdk
import { init, convertIP } from '@ip2geo/sdk'

init({ authKey: process.env.IP2GEO_API_KEY! })

const result = await convertIP('8.8.8.8')
if (result.success) {
    console.log(result.data.continent.country.name)
}

The SDK gives you typed responses, automatic retries with sensible defaults, and built-in caching. For TypeScript projects this is the cleanest path.

Getting the Client’s Real IP

The first hard part of geolocation in any web framework is getting the right IP. Naive code uses req.socket.remoteAddress, which works in dev but breaks in production behind any proxy/load balancer/CDN.

The right pattern reads forwarded headers, but only when you trust them:

import type { Request } from 'express'

function getClientIp(req: Request): string {
    // X-Forwarded-For is the standard. The leftmost IP is the original client.
    const xff = req.headers['x-forwarded-for']
    if (typeof xff === 'string') {
        return xff.split(',')[0].trim()
    }
    
    // Cloudflare-specific (more trustworthy if you're definitely behind Cloudflare)
    const cf = req.headers['cf-connecting-ip']
    if (typeof cf === 'string') return cf
    
    // Fall back to the socket address
    return req.socket.remoteAddress ?? ''
}

Critical: only trust forwarded headers when you control the proxy. If your server is reachable directly (not always behind your load balancer), an attacker can set X-Forwarded-For themselves. The right pattern is to configure your reverse proxy to strip incoming forwarded headers and set them itself.

In Express, app.set('trust proxy', true) lets you use req.ip, which respects X-Forwarded-For correctly. Same in NestJS via the @RealIp() decorator. In Fastify, request.ip works similarly if you configure trustProxy.

Express Middleware: Drop-in Pattern

import express from 'express'
import { init, convertIP } from '@ip2geo/sdk'

init({ authKey: process.env.IP2GEO_API_KEY! })

const app = express()
app.set('trust proxy', true)

app.use(async (req, res, next) => {
    try {
        const result = await convertIP(req.ip)
        if (result.success) {
            res.locals.geo = result.data
        }
    } catch (err) {
        console.error('geo lookup failed', err)
    }
    next()
})

app.get('/', (req, res) => {
    const country = res.locals.geo?.continent?.country?.name ?? 'Unknown'
    res.send(`Hello from ${country}`)
})

Every request gets res.locals.geo populated. Downstream handlers branch on country, currency, language, ASN — whatever they need. If the lookup fails (network blip, rate limit), the middleware swallows the error and lets the request continue without geo data. Better than failing the whole request over an enrichment service.

Caching: The Make-Or-Break Step

The middleware above calls the API on every request. With any meaningful traffic this is wasteful and slow. The geo of an IP doesn’t change between two requests one second apart.

The simplest cache is in-memory:

import { LRUCache } from 'lru-cache'

const geoCache = new LRUCache<string, any>({
    max: 10_000,
    ttl: 60_000  // 60 seconds
})

app.use(async (req, res, next) => {
    const ip = req.ip
    const cached = geoCache.get(ip)
    if (cached) {
        res.locals.geo = cached
        return next()
    }
    
    try {
        const result = await convertIP(ip)
        if (result.success) {
            geoCache.set(ip, result.data)
            res.locals.geo = result.data
        }
    } catch (err) {
        console.error('geo lookup failed', err)
    }
    next()
})

A 60-second cache reduces upstream calls dramatically for repeat visitors. For higher-traffic services, see caching strategies for the full discussion of in-memory, Redis, and edge caching.

Edge Functions (Vercel, Cloudflare Workers, Netlify)

Edge functions run close to the user, which makes IP geolocation a natural fit — you’re already at low latency, and the geo API is also at low latency.

// Cloudflare Workers
export default {
    async fetch(request: Request): Promise<Response> {
        const ip = request.headers.get('cf-connecting-ip')
        const apiKey = (globalThis as any).IP2GEO_API_KEY as string
        
        const geoRes = await fetch(`https://api.ip2geo.dev/convert?ip=${ip}`, {
            headers: { 'X-Api-Key': apiKey }
        })
        const geoData = await geoRes.json()
        
        const country = geoData.data?.continent?.country?.name ?? 'Unknown'
        return new Response(`Hello from ${country}`)
    }
}

For Cloudflare Workers specifically, you often don’t even need to call out: request.cf includes country, city, and other geo fields directly. Use the API call when you need more (ASN classification, VPN detection) than what Cloudflare’s built-in headers provide.

For Vercel Edge Functions, you have req.geo baked in (country, region, city) — again, useful for the simple case and worth augmenting with an API call for richer data.

Bulk Lookups

If you’re processing a batch of IPs (analytics rollup, log ingestion, fraud-pipeline backfill), one request per IP is slow. The SDK has a bulk method:

import { convertIPs } from '@ip2geo/sdk'

const ips = ['8.8.8.8', '1.1.1.1', '9.9.9.9', '64.6.64.6']
const result = await convertIPs(ips)

if (result.success) {
    for (const entry of result.data) {
        console.log(entry.ip, entry.continent.country.name)
    }
}

One HTTP round-trip for the whole batch. Significantly faster than serial requests when you have many IPs to look up.

Handling IPv6

Code that assumes IPv4 is one of the most common bugs in IP handling. IPv6 addresses look completely different:

2001:0db8:85a3:0000:0000:8a2e:0370:7334

Your geolocation API call should work for both — no special casing needed:

const result = await convertIP('2001:4860:4860::8888')  // Google DNS over IPv6

But your IP extraction code needs to handle the format. The X-Forwarded-For header for an IPv6 client looks like 2001:db8::1, 198.51.100.10. Don’t accidentally try to parse it with IPv4-only regex.

Node’s stdlib has helpers:

import net from 'node:net'

const ip = '2001:db8::1'
if (net.isIPv6(ip)) {
    console.log('IPv6 client')
} else if (net.isIPv4(ip)) {
    console.log('IPv4 client')
}

Common Pitfalls

1. Logging the raw IP everywhere

Under GDPR, IP addresses are personal data. Default to logging the enriched country/ASN/city rather than the raw IP. If you need the IP for security/abuse purposes, bound the retention.

2. Blocking on the geo lookup

If your handler awaits the geo lookup before responding, your page load latency includes that lookup. Cache aggressively, or do the lookup after responding (set the geo asynchronously in the background and use it for the next request).

3. Not handling unknown IPs

Private ranges, anycast addresses, very newly allocated ranges — all of these can return incomplete or no data. Your code should treat geo === null and geo.country === undefined as valid states.

4. Using socket.remoteAddress in production

Always app.set('trust proxy', true) (or equivalent) and use the framework’s IP-extraction helper. Otherwise every user appears to come from your load balancer.

5. Not retrying transient failures

The SDK retries by default, but if you’re calling fetch directly, wrap in a retry policy. A single 502 from the API shouldn’t fail your page request.

6. Mixing your API key into client-side code

The API key is server-side. Never expose it in browser JavaScript. If you need to call the API from the browser, run it through your own backend or use a public-key-style approach with origin restrictions on the dashboard.

Picking Between SDK and Raw fetch

The official SDK is recommended for:

  • TypeScript projects (typed responses).
  • Apps that do batch lookups (built-in batching).
  • Apps that need built-in caching (LRU cache included).
  • Apps that benefit from automatic retries.

Raw fetch is fine for:

  • Quick scripts and prototypes.
  • Edge functions where bundle size matters.
  • Very specific use cases where you already have a caching/retry layer.

Both are first-class. The API itself is the same.

A Production-Ready Skeleton

Putting it together, here’s an Express middleware that you can drop into a real production codebase:

import express from 'express'
import { LRUCache } from 'lru-cache'
import { init, convertIP } from '@ip2geo/sdk'

init({ authKey: process.env.IP2GEO_API_KEY! })

const geoCache = new LRUCache<string, any>({ max: 10_000, ttl: 60_000 })

export function geoMiddleware() {
    return async (req: any, res: any, next: any) => {
        const ip = req.ip
        if (!ip || ip === '::1' || ip === '127.0.0.1') return next()
        
        let geo = geoCache.get(ip)
        
        if (!geo) {
            try {
                const result = await convertIP(ip)
                if (result.success && result.data) {
                    geo = result.data
                    geoCache.set(ip, geo)
                }
            } catch (err) {
                console.error('[geo]', err)
            }
        }
        
        if (geo) res.locals.geo = geo
        next()
    }
}

const app = express()
app.set('trust proxy', true)
app.use(geoMiddleware())

app.get('/', (req, res) => {
    res.json({ country: res.locals.geo?.continent?.country?.code ?? 'unknown' })
})

Drop-in, cache-backed, fail-soft. Adjust the cache size and TTL based on your traffic and how stale you can tolerate.

TL;DR

  • Get the right client IP — use req.ip with trust proxy, not socket.remoteAddress.
  • Use the SDK for typed responsesnpm install @ip2geo/sdk.
  • Cache lookups in-memory — 60-second LRU cache cuts upstream load 10x or more.
  • Edge functions are a natural fit — and CDNs often give you basic geo for free.
  • Batch when you canconvertIPs([...]) for bulk operations.
  • Handle IPv6 and unknown IPs — both are valid states; don’t crash on them.
  • Don’t expose the API key to the browser — server-side only.

If you’re starting fresh, the Ip2Geo free tier covers 1,000 conversions/month — enough to wire up the middleware, see what your traffic looks like, and decide whether to upgrade. For Python developers, the Python integration guide covers the same ground with requests, FastAPI, and Django patterns.

Get Started

Convert IPs into accurate location data in milliseconds.

Sign up today and get 1,000 free monthly stored conversions, and discover why developers trust us for fast, reliable, and affordable IP conversions.