How to Build Region-Aware APIs with IP Geolocation
Content localization at the API layer starts with one fact: you already know where your users are. A user in Tokyo gets timestamps in UTC. A customer in Berlin sees prices in USD. A visitor in São Paulo gets English error messages. The API technically works, but it ignores the caller’s IP address, which tells you their country, local currency, IANA timezone, and spoken languages.
One API call to an IP geolocation service returns all of that. This tutorial builds an Express middleware that makes the call once, caches the result, and attaches region context to every request, allowing your route handlers to adapt without needing to think about it.
By the end, your API responses will include localized timestamps, the correct currency code, a language hint, and a flag for EU compliance. The middleware handles caching, timeouts, and fallback behavior, allowing individual routes to remain clean.
TL;DR
- Build an Express middleware that resolves the caller’s IP to country, currency, timezone, and language using ipgeolocation.io
- Cache results in Redis with a 24-hour TTL, so you’re not making a geolocation call on every request
- Fail open: if the geo lookup fails, the request continues with a sensible default region instead of returning a 500
- Adapt API responses per region: format timestamps in the caller’s timezone, return their local currency code, and surface
is_eufor GDPR consent logic - The pattern works for any backend that serves international users: SaaS dashboards, e-commerce APIs, billing services, and content platforms
One IP geolocation call gives you currency (ISO 4217 code, name, symbol), the IANA timezone string, an array of spoken language codes, and an EU membership boolean. The middleware below wires all of that into req.geo so your route handlers can use it without importing anything.
What “region-aware” actually means
This isn’t about multi-region infrastructure or deploying to AWS us-east-1 and eu-west-1. Region-aware in this context means your API reads the caller’s location and changes its behavior accordingly.
Concretely, a region-aware API can return "currency": "EUR" instead of hardcoded "currency": "USD" when the caller is in Germany. It can format created_at as, 2026-05-12 14:30 CEST instead of 2026-05-12T12:30:00Z. It can set a Content-Language header to de before the request even hits your i18n layer. And it can flag EU-originating requests so your consent middleware knows to fire.
None of this requires the user to fill out a profile or select preferences. Their IP address carries enough signal for all four adaptations.
The API call that returns everything you need
A single request to ipgeolocation.io’s /v3/ipgeo endpoint with the right fields parameter returns geolocation, currency, timezone, and country metadata in one response. Here's what comes back for a Swedish IP:
curl "https://api.ipgeolocation.io/v3/ipgeo?apiKey=API_KEY&ip=91.128.103.196&fields=location,currency,country_metadata,time_zone"{
"ip": "91.128.103.196",
"location": {
"continent_code": "EU",
"continent_name": "Europe",
"country_code2": "SE",
"country_code3": "SWE",
"country_name": "Sweden",
"country_name_official": "Kingdom of Sweden",
"country_capital": "Stockholm",
"state_prov": "Stockholms län",
"state_code": "SE-AB",
"district": "Stockholm",
"city": "Stockholm",
"zipcode": "164 40",
"latitude": "59.40510",
"longitude": "17.95510",
"is_eu": true,
"country_flag": "https://ipgeolocation.io/static/flags/se_64.png",
"geoname_id": "9972319",
"country_emoji": "🇸🇪"
},
"country_metadata": {
"calling_code": "+46",
"tld": ".se",
"languages": ["sv-SE", "se", "sma", "fi-SE"]
},
"currency": {
"code": "SEK",
"name": "Swedish Krona",
"symbol": "kr"
},
"time_zone": {
"name": "Europe/Stockholm",
"offset": 1,
"offset_with_dst": 1,
"current_time": "2026-05-12 14:37:38.987+0200",
"current_time_unix": 1772876258.987,
"current_tz_abbreviation": "CEST",
"current_tz_full_name": "Central European Summer Time",
"is_dst": true,
"dst_savings": 1,
"dst_exists": true
}
}From this single response, the middleware extracts: location.country_code2 for routing, location.is_eu for compliance, currency.code and currency.symbol for pricing, time_zone.name for timestamp formatting, and country_metadata.languages (an array, not a string) for language negotiation.
Build the middleware
Project setup
mkdir region-aware-api && cd region-aware-api
npm init -y
npm install express ioredisThe project needs three things: Express for the HTTP layer, ioredis for Redis caching, and Node 18+ for built-in fetch (no extra HTTP library needed).
Create a .env file for the API key. Never hardcode it.
IPGEO_API_KEY=your_key_here
REDIS_URL=redis://localhost:6379The geo-enrichment middleware
This is the core of the pattern. Every incoming request gets an req.geo object attached before it reaches any route handler.
// middleware/geoEnrich.js
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.IPGEO_API_KEY;
const CACHE_TTL = 86400; // 24 hours in seconds
// Fallback when geo lookup fails or cache is empty.
// Fail open: the request continues with defaults instead of breaking.
const DEFAULT_GEO = {
country: "US",
countryName: "United States",
isEu: false,
currency: { code: "USD", name: "US Dollar", symbol: "$" },
timezone: "America/New_York",
languages: ["en"],
};
function extractClientIp(req) {
// trust proxy must be configured in Express for this to be safe
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
return forwarded.split(",")[0].trim();
}
return req.ip || req.socket?.remoteAddress || "";
}
async function lookupGeo(ip) {
const fields = "location,currency,country_metadata,time_zone";
const url = `https://api.ipgeolocation.io/v3/ipgeo?apiKey=${API_KEY}&ip=${ip}&fields=${fields}`;
const response = await fetch(url, {
signal: AbortSignal.timeout(1500), // hard timeout: don't let a slow lookup block your API
});
if (!response.ok) {
throw new Error(`Geo API returned ${response.status}`);
}
const data = await response.json();
return {
country: data.location?.country_code2 ?? "US",
countryName: data.location?.country_name ?? "United States",
isEu: data.location?.is_eu ?? false,
currency: {
code: data.currency?.code ?? "USD",
name: data.currency?.name ?? "US Dollar",
symbol: data.currency?.symbol ?? "$",
},
timezone: data.time_zone?.name ?? "America/New_York",
languages: data.country_metadata?.languages ?? ["en"],
};
}
export async function geoEnrich(req, res, next) {
const ip = extractClientIp(req);
// Skip geo lookup for private/loopback IPs (local dev)
if (!ip || ip === "127.0.0.1" || ip === "::1") {
req.geo = DEFAULT_GEO;
return next();
}
try {
// Check Redis cache first
const cached = await redis.get(`geo:${ip}`);
if (cached) {
req.geo = JSON.parse(cached);
return next();
}
// Cache miss: call the API
const geo = await lookupGeo(ip);
req.geo = geo;
// Cache asynchronously. Don't block the response on a Redis write.
redis.set(`geo:${ip}`, JSON.stringify(geo), "EX", CACHE_TTL).catch(() => {});
return next();
} catch (err) {
// Fail open. Log the error, attach defaults, keep going.
console.error(`Geo lookup failed for ${ip}:`, err.message);
req.geo = DEFAULT_GEO;
return next();
}
}A few decisions worth calling out. The 1,500ms timeout via AbortSignal.timeout is aggressive on purpose. If the geolocation API is slow for any reason, your users shouldn't wait. The default region fills in, the request completes, and you can investigate the timeout in your logs later. This is a fail-open pattern: showing a user USD instead of SEK is annoying, but returning a 500 error is worse.
The Redis cache write is fire-and-forget (.catch(() => {})). If Redis is down, the middleware still works. It just makes a fresh API call on the next request from the same IP. The cache is an optimization, not a dependency.
Trust proxy and X-Forwarded-For
If your Express app sits behind a reverse proxy (nginx, Cloudflare, a load balancer), req.ip returns the proxy's IP, not the user's. You need to tell Express to read X-Forwarded-For:
// app.js
import express from "express";
import { geoEnrich } from "./middleware/geoEnrich.js";
const app = express();
// Only trust the first proxy hop. Setting this to true trusts
// every proxy in the chain, which lets clients spoof their IP.
app.set("trust proxy", 1);
app.use(geoEnrich);If you skip trust proxy, every request resolves to your load balancer's IP. Every user gets the same geolocation result (wherever your proxy is hosted), and you've built a very expensive way to hardcode a single region.
Adapt responses: content localization in practice
Now that req.geo exists on every request, route handlers can use it without importing anything or making their own API calls.
Currency and pricing
For a SaaS billing API, the simplest adaptation is returning the user’s local currency alongside your pricing data:
// routes/pricing.js
const PRICING_TIERS = {
starter: { usd: 19 },
pro: { usd: 79 },
enterprise: { usd: 249 },
};
app.get("/api/pricing", (req, res) => {
const { currency, country } = req.geo;
res.json({
tiers: PRICING_TIERS,
display_currency: {
code: currency.code,
symbol: currency.symbol,
},
// Signal to the frontend which currency to display.
// Actual conversion rates come from your payment provider.
region: country,
});
});This doesn’t do currency conversion on the backend (your payment provider, like Stripe, handles that). It tells the frontend which currency symbol and code to display by default, so a visitor from Japan sees ¥ and a visitor from the UK sees £ without selecting anything.
For actual location-based pricing where different regions get different price points, map country codes to pricing tiers:
const REGION_TIERS = {
US: "standard", CA: "standard", GB: "standard", AU: "standard",
DE: "eu", FR: "eu", SE: "eu", NL: "eu",
IN: "emerging", BR: "emerging", PH: "emerging", NG: "emerging",
};This pattern powers purchasing power parity pricing. A SaaS product at $79/month in the US might cost $39/month in India. The middleware supplies the country code; the pricing logic maps it to a tier and multiplier.
Timezone-aware timestamps
Returning timestamps in UTC is correct from a storage perspective, but it forces every client to do timezone math. If you already know the user’s timezone, you can return both:
app.get("/api/dashboard", (req, res) => {
const events = getRecentEvents(req.user.id);
const formatted = events.map((event) => ({
...event,
created_at_utc: event.created_at,
created_at_local: new Intl.DateTimeFormat("en-US", {
timeZone: req.geo.timezone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(event.created_at)),
timezone: req.geo.timezone,
}));
res.json({ events: formatted, region: req.geo });
});Intl.DateTimeFormat with the IANA timezone string (like Europe/Stockholm) handles DST transitions correctly. No offset math, no moment-timezone dependency.
Get Abdul Mateen’s stories in your inbox
Join Medium for free to get updates from this writer.
For applications that need richer timezone data (current local time, DST transition dates, UTC offsets), ipgeolocation.io also provides a dedicated Timezone API at /v3/timezone that returns the full timezone object without the geolocation overhead.
Language negotiation
The country_metadata.languages field returns an array of language codes spoken in the user's country. For Sweden, that's ["sv-SE", "se", "sma", "fi-SE"]. This is a reasonable starting point for language selection, but it shouldn't override an explicit user preference.
A practical fallback chain:
function resolveLanguage(req) {
// 1. Explicit user setting always wins
if (req.user?.preferred_language) {
return req.user.preferred_language;
}
// 2. Accept-Language header (browser-level preference)
const acceptLang = req.headers["accept-language"];
if (acceptLang) {
// Take the primary language tag before any q-values
const primary = acceptLang.split(",")[0].split(";")[0].trim();
if (primary.length >= 2) return primary;
}
// 3. IP-detected country languages as last resort
const geoLangs = req.geo?.languages;
if (geoLangs && geoLangs.length > 0) {
return geoLangs[0]; // first listed language for the country
}
return "en";
}The IP-based language is the fallback, not the primary source. A German developer working from a coffee shop in Tokyo probably still wants English. But for first-time visitors with no saved preferences and a generic Accept-Language header, the IP-detected language is better than defaulting to English every time.
EU compliance flags
location.is_eu is a boolean that tells you whether the IP originates from an EU member state. For GDPR consent logic, this is the simplest trigger available:
app.use((req, res, next) => {
if (req.geo.isEu) {
res.set("X-Consent-Required", "true");
// Your consent middleware or frontend can read this header
}
next();
});This is a signal, not a legal opinion. It tells your consent layer to activate for EU traffic. The actual consent mechanism (banner, cookie preferences, opt-in forms) lives in your frontend or a dedicated service.
Cache geo lookups
The middleware above already includes Redis caching, but it’s worth explaining why the cache matters and how to tune it.
ipgeolocation.io’s free tier allows 1,000 requests per day (~30,000 per month). With Redis caching at a 24-hour TTL, each unique IP makes exactly one API call per day. For a SaaS product with 10,000 daily active users, that’s 10,000 cache misses on the first visit and zero API calls for the rest of the day. You’ll stay well within the free tier unless you’re serving a very large number of unique IPs daily.
The cache key is the full IP address (geo:${ip}). For higher-volume applications, you could cache by /24 subnet (the first three octets of an IPv4 address), since most IPs within the same /24 resolve to the same city. That trades some accuracy for a significant reduction in cache misses:
// Subnet-level caching: trades city accuracy for cache efficiency
function subnetKey(ip) {
const parts = ip.split(".");
if (parts.length === 4) {
return `geo:${parts[0]}.${parts[1]}.${parts[2]}.0`;
}
return `geo:${ip}`; // fall back to full IP for IPv6
}Whether to cache per-IP or per-subnet depends on your accuracy needs. Country and currency are the same across a /24 in almost all cases. City can differ if the subnet spans a metro area. For the use cases in this tutorial (currency, timezone, language, EU flag), per-IP caching is the safer default.
The same pattern in Python
The middleware concept translates directly to FastAPI’s dependency injection:
# dependencies/geo.py
import os
import httpx
import redis.asyncio as redis
IPGEO_API_KEY = os.environ["IPGEO_API_KEY"]
redis_client = redis.from_url(https://medium.com/os.environ.get("REDIS_URL",%20"redis://localhost:6379"))
DEFAULT_GEO = {
"country": "US",
"is_eu": False,
"currency": {"code": "USD", "symbol": "$"},
"timezone": "America/New_York",
"languages": ["en"],
}
async def get_geo(request):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
ip = ip or request.client.host
if not ip or ip in ("127.0.0.1", "::1"):
return DEFAULT_GEO
cache_key = f"geo:{ip}"
try:
cached = await redis_client.get(cache_key)
if cached:
import json
return json.loads(cached)
async with httpx.AsyncClient(timeout=1.5) as client:
resp = await client.get(
"https://api.ipgeolocation.io/v3/ipgeo",
params={
"apiKey": IPGEO_API_KEY,
"ip": ip,
"fields": "location,currency,country_metadata,time_zone",
},
)
resp.raise_for_status()
data = resp.json()
geo = {
"country": data.get("location", {}).get("country_code2", "US"),
"is_eu": data.get("location", {}).get("is_eu", False),
"currency": {
"code": data.get("currency", {}).get("code", "USD"),
"symbol": data.get("currency", {}).get("symbol", "$"),
},
"timezone": data.get("time_zone", {}).get("name", "America/New_York"),
"languages": data.get("country_metadata", {}).get("languages", ["en"]),
}
import json
await redis_client.set(cache_key, json.dumps(geo), ex=86400)
return geo
except Exception:
return DEFAULT_GEOUse it as a FastAPI dependency:
from fastapi import Depends, Request
@app.get("/api/pricing")
async def pricing(request: Request, geo=Depends(get_geo)):
return {"currency": geo["currency"], "region": geo["country"]}Same pattern, same caching, same fail-open behavior. The language doesn’t change the architecture.
What to watch out for
VPNs and proxies: If a user is tunneling through a VPN, the API returns the VPN server’s location, not the user’s actual country. For content localization, this is usually acceptable: the user chose to appear in that region. For compliance or tax purposes, you might want to detect VPN usage with ipgeolocation.io’s Security API and prompt the user to confirm their real location.
Fail-open, always: The geo middleware should never cause a 500. If the API is unreachable, Redis is down, or the IP resolves to nothing useful, fall back to defaults and move on. A user seeing USD instead of EUR is a minor UX issue. A broken API response is a support ticket.
IPv6: The ipgeolocation.io API handles IPv4 and IPv6 identically. Your middleware should too. The extractClientIp function above already works with both. The only gotcha is subnet caching: the /24 shortcut applies to IPv4 only. For IPv6, cache by the full address or by /48 prefix.
Local development: During development, req.ip returns 127.0.0.1 or ::1. The middleware skips the API call and returns defaults. To test with real geo data, pass an explicit IP parameter to your API: /api/pricing?debug_ip=91.128.103.196. Gate this behind a development-only flag.
Don’t over-localize: IP geolocation tells you where the request comes from, not what the user wants. A Brazilian developer working from Berlin probably doesn’t want Portuguese. The IP-detected region is the right default for anonymous visitors. For authenticated users, a saved preference should always take precedence.
Frequently Asked Questions
Does IP geolocation work for IPv6 addresses?
Yes. ipgeolocation.io resolves both IPv4 and IPv6 addresses with the same endpoint and response format. The fields parameter, caching, and response shape are identical. Your middleware doesn't need separate handling for the two address types.
How accurate is IP-based country detection?
Country-level accuracy is above 95% for most commercial IP ranges. City-level accuracy is lower (roughly 85%), but the middleware in this tutorial only depends on country-level data for currency, timezone, and EU detection. For those use cases, the accuracy is more than sufficient.
What happens if the geolocation API is down?
The middleware fails open. The try/catch block catches any error (network timeout, HTTP error, JSON parse failure) and attaches a default region object to the request. Your route handlers never see the failure. Monitor geo lookup errors in your logs and alert if the failure rate exceeds a threshold.
Should I cache IP geolocation results?
Always. Without caching, every API request triggers a geolocation lookup. With a 24-hour Redis TTL, each unique IP costs one lookup per day. For most SaaS products, this keeps you well within ipgeolocation.io’s free tier of 1,000 daily requests. Paid plans start at $19/month for 150,000 requests if you outgrow the free tier.
Is IP-based content localization GDPR compliant?
Detecting a country from an IP address for UX purposes (currency display, timezone formatting) is generally covered by legitimate interest under GDPR. You’re not storing personal data or building a tracking profile. The IP is used ephemerally for region detection and cached as a country code, not as an identifier. Consult your legal team for your specific case.
Can I detect the user’s preferred language from their IP?
Partially. The country_metadata.languages field returns an array of languages spoken in the user's country, but it doesn't tell you which language the specific user prefers. Use it as a last-resort fallback behind the Accept-Language header and any explicit user preference. It's better than defaulting to English for every visitor, but it won't replace proper i18n user settings.
What is the difference between IP geolocation and browser geolocation for localization?
Browser geolocation (the navigator.geolocation API) requires user permission and returns GPS-level coordinates. IP geolocation requires no permission, works server-side, and returns country/city-level data plus structured metadata (currency, timezone, language). For API-level localization where you need the answer before the first response, IP geolocation is the right tool. Browser geolocation is for map-based features that need precise coordinates.
Wrapping up
The middleware pattern is simple: extract the IP, check the cache, call the API on a miss, attach the result to the request, and fail open if anything goes wrong. Everything after that is just reading req.geo in your route handlers.
If you’re building for international users and currently hardcoding USD, UTC, or en, this takes under an hour to add. Sign up for a free API key, wire up the middleware, and start using the region context you already have access to.
For applications that outgrow the free tier or need sub-millisecond lookup times, ipgeolocation.io also offers downloadable MMDB databases that run locally with no network calls at all. Same data, zero latency.







