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: ioredis with the same subnetKey pattern.
  • Async middleware: geoMiddleware adds ~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 injects req.geo with country and currency
  • node-cache: in-memory TTL cache at /24 subnet level
  • getExchangeRate: 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