Node.jsExpressTutorialGeolocationMiddleware
IP Geolocation Middleware for Express.js: Country Detection and Currency Rates
ApogeoAPI5 min read
Express.js is the most-used Node.js framework. Adding IP geolocation as middleware means every route handler gets country and currency data on req.geo without any extra code.
Installation
npm install axios node-cache
npm install -D @types/node
APOGEOAPI_KEY=your_api_key_here
GeoService Module
// src/geo/geo.service.ts
import axios from 'axios';
import NodeCache from 'node-cache';
const GEO_API_BASE = 'https://api.apogeoapi.com/v1';
const cache = new NodeCache({ stdTTL: 4 * 3600, useClones: false }); // 4h TTL
export interface GeoData {
countryCode: string;
countryName: string;
currencyCode: string;
city?: string;
timezone?: string;
}
const FALLBACK: GeoData = {
countryCode: 'US',
countryName: 'United States',
currencyCode: 'USD',
};
const PRIVATE_RANGES = ['127.', '10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', '192.168.', '::1'];
function isPrivateIp(ip: string): boolean {
return PRIVATE_RANGES.some(r => ip.startsWith(r));
}
// Cache at /24 subnet level — reduces unique keys 250x
function subnetKey(ip: string): string {
const parts = ip.split('.');
if (parts.length === 4) return parts.slice(0, 3).join('.') + '.0';
return ip; // IPv6: use full IP
}
export async function geolocate(ip: string): Promise<GeoData> {
if (isPrivateIp(ip)) return FALLBACK;
const key = `geo:${subnetKey(ip)}`;
const cached = cache.get<GeoData>(key);
if (cached) return cached;
try {
const { data } = await axios.get<GeoData>(`${GEO_API_BASE}/ip/${ip}`, {
headers: { 'X-API-Key': process.env.APOGEOAPI_KEY },
timeout: 3000,
});
const result = { countryCode: data.countryCode ?? 'US', countryName: data.countryName ?? 'United States', currencyCode: data.currencyCode ?? 'USD', city: data.city, timezone: data.timezone };
cache.set(key, result);
return result;
} catch {
return FALLBACK;
}
}
export async function getExchangeRate(currency: string): Promise<number> {
if (currency === 'USD') return 1;
const key = `fx:${currency}`;
const cached = cache.get<number>(key);
if (cached !== undefined) return cached;
try {
const { data } = await axios.get(`${GEO_API_BASE}/currencies/${currency}/rate`, {
headers: { 'X-API-Key': process.env.APOGEOAPI_KEY },
timeout: 3000,
});
const rate = data.usdRate ?? 1;
cache.set(key, rate);
return rate;
} catch {
return 1;
}
}
Express Middleware
// src/middleware/geo.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { geolocate, GeoData } from '../geo/geo.service';
// Extend Express Request to include geo data
declare global {
namespace Express {
interface Request {
geo: GeoData;
}
}
}
function resolveIp(req: Request): string {
const cfIp = req.headers['cf-connecting-ip'];
if (typeof cfIp === 'string') return cfIp.trim();
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') return forwarded.split(',')[0].trim();
return req.ip ?? '127.0.0.1';
}
export async function geoMiddleware(req: Request, _res: Response, next: NextFunction) {
const ip = resolveIp(req);
req.geo = await geolocate(ip);
next();
}
App Setup
// src/app.ts
import express from 'express';
import { geoMiddleware } from './middleware/geo.middleware';
import pricingRoutes from './routes/pricing';
const app = express();
app.use(express.json());
// Apply geo middleware globally — all routes get req.geo
app.use(geoMiddleware);
app.use('/pricing', pricingRoutes);
export default app;
Pricing Route
// src/routes/pricing.ts
import { Router, Request, Response } from 'express';
import { getExchangeRate } from '../geo/geo.service';
const router = Router();
const CURRENCY_SYMBOLS: Record<string, string> = {
EUR: '€', GBP: '£', BRL: 'R$', ARS: '$', MXN: '$', CLP: '$',
JPY: '¥', CNY: '¥', INR: '₹',
};
const PLANS = [
{ name: 'Basic', usd: 19 },
{ name: 'Starter', usd: 29 },
{ name: 'Professional', usd: 79 },
];
router.get('/', async (req: Request, res: Response) => {
const { countryCode, currencyCode } = req.geo;
const rate = await getExchangeRate(currencyCode);
const symbol = CURRENCY_SYMBOLS[currencyCode] ?? currencyCode + ' ';
const plans = PLANS.map(p => ({
...p,
local: {
amount: Math.round(p.usd * rate),
currency: currencyCode,
formatted: symbol + Math.round(p.usd * rate).toLocaleString('en-US'),
},
}));
res.json({ country: countryCode, currency: currencyCode, plans });
});
export default router;
Route-Level Geo (skip for static routes)
If you only need geo on specific routes, use it as a route-level middleware instead of globally:
import { geoMiddleware } from './middleware/geo.middleware';
// Only runs for /checkout and /pricing
router.use('/checkout', geoMiddleware, checkoutHandler);
router.use('/pricing', geoMiddleware, pricingHandler);
API Endpoint — for SPA frontends
// src/routes/visitor.ts
import { Router, Request, Response } from 'express';
const router = Router();
router.get('/geo', (req: Request, res: Response) => {
res.json({
countryCode: req.geo.countryCode,
countryName: req.geo.countryName,
currencyCode: req.geo.currencyCode,
});
});
export default router;
Testing with supertest
// src/__tests__/geo.test.ts
import request from 'supertest';
import app from '../app';
describe('GET /visitor/geo', () => {
it('returns country and currency', async () => {
const res = await request(app)
.get('/visitor/geo')
.set('X-Forwarded-For', '8.8.8.8'); // simulate non-localhost IP
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('countryCode');
expect(res.body).toHaveProperty('currencyCode');
});
it('returns fallback for localhost', async () => {
const res = await request(app).get('/visitor/geo');
expect(res.body.countryCode).toBe('US');
});
});
Koa.js equivalent
If you're using Koa instead of Express:
// koa/middleware/geo.ts
import { Context, Next } from 'koa';
import { geolocate } from './geo.service';
export async function geoMiddleware(ctx: Context, next: Next) {
const ip = ctx.headers['cf-connecting-ip'] as string
?? ctx.headers['x-forwarded-for']?.split(',')[0]
?? ctx.ip;
ctx.state.geo = await geolocate(ip);
await next();
}
Performance Notes
- node-cache stores data in process memory — zero network overhead on cache hits. For multi-process apps (PM2 cluster mode), use Redis instead:
iorediswith the samesubnetKeypattern. - Async middleware:
geoMiddlewareadds ~0ms on cache hit, ~20-50ms on miss. For high-traffic routes, consider skipping geo for health-check, assets, and webhook endpoints. - Rate limits: Free tier is 1,000 req/month. With /24 subnet caching, each unique office/ISP subnet counts as 1 request — covering many users per cache entry.
Summary
geoMiddleware: async Express middleware that injectsreq.geowith country and currencynode-cache: in-memory TTL cache at /24 subnet levelgetExchangeRate: lazy FX lookup, also cached 4h- Works with Express 4+, Koa, Fastify (similar pattern)
API docs: api.apogeoapi.com/api/docs — free tier 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