
From “Just Show Local Prices” to a Robust, Auditable Currency System in Next.js
A practical, learner-friendly walkthrough you can ship (with detection, normalization, multi-provider FX, caching, and a tiny Switch Currency UI).
I’m not a currency or i18n expert—just a builder sharing exactly what worked for me, what broke, and how I patched it while fixing pricing pages for Postly and Onu in Next.js. If you want your pricing page to feel local and behave reliably in production, this is for you.
MAF → MAD, FRF/DEM/ESP → EUR).exchangerate.host → open.er-api.com → jsDelivr (fawazahmed0) → last-resort fallback.Intl.NumberFormat; use symbol fallbacks where needed.“Show local prices” sounds trivial—until you meet:
MAF showed up in my data; modern code is MAD).Treat this as a minimum viable reliability pattern you can adapt.
Client (useCurrency hook):
├─ Reads cookie override (if set by user switcher)
├─ Detects TZ → maps to country → maps to currency (from static JSON)
├─ Normalizes legacy codes (MAF→MAD, FRF→EUR, ...)
├─ Looks up session-cache FX rate (base→target)
└─ If missing → calls /api/rates?base=USD&target=NGN
Server (/api/rates route):
├─ Try exchangerate.host/convert
├─ Try exchangerate.host/latest
├─ Try open.er-api.com/v6/latest
├─ Try jsDelivr fawazahmed0 files
└─ Fallback { rate: 1 }
↳ CDN-cache headers (12h, stale-while-revalidate)
Africa/Lagos → NG).countries.json with countryCode, currencyCode, currencySymbol.const normalizeLegacyCode = (code) => {
const map = {
// Euro legacy to EUR
FRF:'EUR', DEM:'EUR', ESP:'EUR', ITL:'EUR', NLG:'EUR', ATS:'EUR', PTE:'EUR', LUF:'EUR', FIM:'EUR', SIT:'EUR',
// Morocco legacy
MAF:'MAD',
// Other renames you’re likely to hit
CSK:'CZK', PLZ:'PLN', BUK:'MMK', ZRZ:'CDF', MXP:'MXN', RUR:'RUB',
YUM:'RSD', YUD:'RSD', UYP:'UYU', VEB:'VES', GHC:'GHS', ZMK:'ZMW',
RHD:'ZWL', KRO:'KRW', MDC:'MDL', MZE:'MZN', MKN:'MKD',
};
return map[String(code || '').toUpperCase()] || String(code || '').toUpperCase();
};
useCurrencyKey responsibilities:
currency_code) if present.Client caching strategy:
sessionStorage: cache (base → target) rate for ~6h.I use a Next.js App Router route.js with a chain:
exchangerate.host (/convert then /latest)open.er-api.com (/v6/latest/:BASE)fawazahmed0 via jsDelivr (static daily files)fallback (rate=1)Each attempt is logged with provider name, status, and a tiny sample of the body (to avoid noisy logs).
/app/api/rates/route.js (core idea)import { NextResponse } from 'next/server';
const REVALIDATE_SECONDS = 60 * 60 * 12;
const EXHOST_CONVERT = 'https://api.exchangerate.host/convert';
const EXHOST_LATEST = 'https://api.exchangerate.host/latest';
const ERAPI_LATEST = 'https://open.er-api.com/v6/latest/';
const FAWAZ_BASE = 'https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies';
const ISO4217 = new Set([...]);
const normalizeLegacyCode = (code) => {
const map = { FRF:'EUR', DEM:'EUR', ESP:'EUR', ITL:'EUR', /* ... */ MAF:'MAD' };
return map[String(code || '').toUpperCase()] || String(code || '').toUpperCase();
};
export async function GET(request) {
const url = new URL(request.url);
let base = normalizeLegacyCode(url.searchParams.get('base') || 'USD');
let target = normalizeLegacyCode(url.searchParams.get('target') || '');
const amount = Number(url.searchParams.get('amount') || 1) || 1;
const debug = url.searchParams.get('debug') === '1';
// ... provider attempts here
}
function withCaching(res) {
res.headers.set('Cache-Control','public, s-maxage=43200, stale-while-revalidate=86400, max-age=300');
return res;
}
// tryExHostConvert, tryExHostLatest, tryOpenERAPI, tryFawazAhmed implementations...
/hooks/useCurrency.js)—key ideas only/api/rates otherwise.setUserCurrency.If you later plug in paid providers:
# .env
CURRENCYLAYER_KEY=…
FIXER_KEY=…
EXCHANGERATE_API_KEY=… # if you move to their paid endpoint
normalizeLegacyCode with inputs from your static JSON./api/rates?base=USD&target=NGN with mocked provider responses.rate=1 fallback. Consider showing USD with an “estimate” badge.If you only need USD, great. But if you want to welcome a global audience, a little work here dramatically improves trust and clarity.
You don’t need a giant i18n project—just a practical pattern:
Detect politely, normalize aggressively, fetch resiliently, cache wisely, and give users a way to switch.
Happy shipping. 🙏