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
TL;DR
- Detect a sensible default currency via timezone → country → currency (privacy-friendly, no IP geo).
- Normalize legacy codes in your static lists (e.g.,
MAF → MAD
,FRF/DEM/ESP → EUR
). - Build a multi-provider FX fallback in a Next.js route: exchangerate.host → open.er-api.com → jsDelivr (fawazahmed0) → last-resort fallback.
- Add CDN caching (12h) + session cache (6h) and minimal logs (client + server) for sanity.
- Render prices with
Intl.NumberFormat
; use symbol fallbacks where needed. - Include a compact, accessible “Switch currency” button that auto-refreshes.
- For checkout integrity, snapshot the applied FX rate into your order record.
Why this matters (even for small teams)
“Show local prices” sounds trivial—until you meet:
- Legacy ISO codes in country lists (Morocco’s
MAF
showed up in my data; modern code isMAD
). - Provider churn (an endpoint you used yesterday starts demanding an API key).
- Hydration & SSR quirks (flicker vs. gating while detecting currency).
- Auditability (what rate did we use at the time of purchase?).
- User agency (let them switch if you guessed wrong).
Treat this as a minimum viable reliability pattern you can adapt.
Design goals
- Respectful detection: No IP lookups. Use timezone → country → currency.
- Graceful degradation: If one provider fails, try another, then fall back.
- Predictable UX: Cache rates and format consistently.
- User control: A small, unobtrusive “Switch currency” widget.
- Auditable: Make it easy to snapshot the rate used at checkout.
Architecture at a glance
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)
Detection without IP: timezone → country → currency
- Keep a
timezone → countryCode
map (e.g.,Africa/Lagos → NG
). - Keep a
countries.json
withcountryCode
,currencyCode
,currencySymbol
. - Normalize legacy currency codes before using them.
Normalization table (partial)
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();
};
Debugging tip: If your UI says “MAF” for Nigeria or Morocco, your static JSON is stale. Normalize it before using.
Client hook: useCurrency
Key responsibilities:
- Read cookie override (
currency_code
) if present. - Derive default from timezone → country → currency.
- Normalize legacy codes.
- Fetch FX when needed (and cache).
- Provide a formatter and a small symbol fallback for rare cases.
Client caching strategy
- SessionStorage: cache (
base → target
) rate for ~6h. - Server: CDN cache (12h + stale-while-revalidate).
Server route: multi-provider FX fallback
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).
Why a chain?
- Resilience: providers fail, change terms, or rate-limit.
- Accuracy: use a provider you trust; keep backups.
- Observability: logs tell you which provider was used.
Caching, hydration, and flicker
- Serversends cache headers:
s-maxage=43200, stale-while-revalidate=86400, max-age=300
- Client holds session FX for ~6h (fast re-renders).
- Hydration: you can gate price rendering until detection is ready, or accept a minor flicker as price formats update. I prefer gating in critical price components and allowing light flicker in non-critical summaries.
UI: the “Switch currency” button
- Bottom-right floating button.
- Compact, keyboard-accessible, labeled “Switch currency”.
- Selecting a currency writes a cookie and auto-refreshes—no “Apply” button.
This is key for correcting detection mistakes and for user agency.
Checkout integrity: snapshot the rate
Even if you convert on the fly, snapshot the applied FX rate at checkout:
- Store
{ base, target, fxRate, provider, timestamp }
with the order. - Use that snapshot for receipts, refunds, and audit trails.
- Consider adding a “Last updated” label on the UI when showing converted totals.
Rounding and minor units
Intl.NumberFormat
handles a lot, but you’ll still want rules:- Always round to 2 decimals for fiat? (Most, but not all.)
- Respect currencies with 0 minor units (e.g., JPY).
- For very large/small numbers, avoid scientific notation; clamp to fixed decimals.
A tiny fallback symbol map helps when Intl
doesn’t recognize a code.
Observability & debugging
Log both sides:
- Client: what you detected (tz, country, raw→normalized code), what you requested, and what rate you applied.
- Server: which provider won, how long each attempt took, and a brief body sample.
This saved me when exchangerate.host suddenly returned missing_access_key
and my NGN rate came from the fallback provider instead.
Security and maintenance notes
- Don’t log entire payloads in production—sample and truncate.
- If you add paid providers, keep keys in environment variables.
- Periodically review your legacy normalization map and country JSON—these go stale.
Code: the pieces you’ll reuse
1) /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(['USD','EUR','GBP','CAD','AUD','NZD','JPY','CNY','INR','NGN','BRL','CHF','SEK','NOK','DKK','PLN','CZK','ZAR','KRW','TRY','AED','SAR','MXN','HKD','SGD','ILS','THB','MYR','PHP','IDR','PKR','RUB','HUF','RON','MAD','MMK','CDF','RSD','UYU','VES','GHS','ZMW','ZWL','MDL','MZN','MKD']);
const normalizeLegacyCode = (code) => {
const map = { FRF:'EUR', DEM:'EUR', ESP:'EUR', ITL:'EUR', NLG:'EUR', ATS:'EUR', PTE:'EUR', LUF:'EUR', FIM:'EUR', SIT:'EUR',
MAF:'MAD', 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();
};
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';
if (!ISO4217.has(base)) base = 'USD';
if (target && base === target) {
const payload = { base, target, amount, rate: 1, rates: { [target]: 1 }, providerUsed: 'short-circuit' };
return withCaching(NextResponse.json(debug ? { ...payload, attempts: [] } : payload));
}
const attempts = [];
const result =
(target && await tryExHostConvert(base, target, amount, attempts))
|| await tryExHostLatest(base, target, attempts)
|| await tryOpenERAPI(base, target, attempts)
|| await tryFawazAhmed(base, target, attempts)
|| { base: 'USD', target, amount: 1, rate: 1, rates: { [target]: 1 }, providerUsed: 'fallback' };
const body = debug ? { ...result, attempts } : result;
return withCaching(NextResponse.json(body));
}
function withCaching(res) {
res.headers.set('Cache-Control','public, s-maxage=43200, stale-while-revalidate=86400, max-age=300');
return res;
}
async function tryExHostConvert(base, target, amount, attempts) {
const qs = new URLSearchParams({ from: base, to: target, amount: String(amount) });
const href = `${EXHOST_CONVERT}?${qs}`;
const t0 = Date.now();
try {
const r = await fetch(href, { next: { revalidate: REVALIDATE_SECONDS } });
const txt = await r.text();
const json = safeJSON(txt);
const rate = Number(json?.result);
const ok = r.ok && Number.isFinite(rate) && rate > 0;
attempts.push(rec('exchangerate.host/convert', href, r.status, ok, txt, t0));
if (ok) return { base, target, amount, rate, rates: { [target]: rate }, providerUsed: 'exchangerate.host/convert' };
} catch (e) {
attempts.push(rec('exchangerate.host/convert', href, 0, false, String(e), t0));
}
return null;
}
async function tryExHostLatest(base, target, attempts) {
const qs = new URLSearchParams({ base }); if (target) qs.set('symbols', target);
const href = `${EXHOST_LATEST}?${qs}`;
const t0 = Date.now();
try {
const r = await fetch(href, { next: { revalidate: REVALIDATE_SECONDS } });
const txt = await r.text();
const json = safeJSON(txt);
const rate = Number(json?.rates?.[target]);
const ok = r.ok && (!target || (Number.isFinite(rate) && rate > 0));
attempts.push(rec('exchangerate.host/latest', href, r.status, ok, txt, t0));
if (ok && target) return { base: json?.base || base, target, amount: 1, rate, rates: { [target]: rate }, providerUsed: 'exchangerate.host/latest' };
return null;
} catch (e) {
attempts.push(rec('exchangerate.host/latest', href, 0, false, String(e), t0));
return null;
}
}
async function tryOpenERAPI(base, target, attempts) {
const href = `${ERAPI_LATEST}${encodeURIComponent(base)}`;
const t0 = Date.now();
try {
const r = await fetch(href, { next: { revalidate: REVALIDATE_SECONDS } });
const txt = await r.text();
const json = safeJSON(txt);
const rates = json?.rates || {};
const rate = Number(rates?.[target]);
const ok = r.ok && json?.result === 'success' && (!target || (Number.isFinite(rate) && rate > 0));
attempts.push(rec('open.er-api.com', href, r.status, ok, txt, t0));
if (ok && target) return { base, target, amount: 1, rate, rates: { [target]: rate }, providerUsed: 'open.er-api.com' };
return null;
} catch (e) {
attempts.push(rec('open.er-api.com', href, 0, false, String(e), t0));
return null;
}
}
async function tryFawazAhmed(base, target, attempts) {
if (!target) return null;
const href = `${FAWAZ_BASE}/${base.toLowerCase()}/${target.toLowerCase()}.json`;
const t0 = Date.now();
try {
const r = await fetch(href, { next: { revalidate: REVALIDATE_SECONDS } });
const txt = await r.text();
const json = safeJSON(txt);
const rate = Number(json?.[target.toLowerCase()]);
const ok = r.ok && Number.isFinite(rate) && rate > 0;
attempts.push(rec('fawazahmed0/currency-api', href, r.status, ok, txt, t0));
if (ok) return { base, target, amount: 1, rate, rates: { [target]: rate }, providerUsed: 'fawazahmed0/currency-api' };
return null;
} catch (e) {
attempts.push(rec('fawazahmed0/currency-api', href, 0, false, String(e), t0));
return null;
}
}
function safeJSON(t){ try { return JSON.parse(t); } catch { return null; } }
function rec(provider,url,status,ok,txt,t0){ const ms=Date.now()-t0; const sample=(txt||'').slice(0,300); console.log('[rates] attempt', JSON.stringify({provider,url,status,ok,ms,sample})); return {provider,url,status,ok,ms,sample}; }
- Client hook (/hooks/useCurrency.js)—key ideas only
Read cookie override.
Detect via timezone map + countries JSON.
Normalize legacy codes.
Session-cache FX; call /api/rates otherwise.
Provide formatter, rate, currencyCode, and setUserCurrency.
(You already have a full version; this is the conceptual summary.)
- “Switch currency” button
Bottom right, labeled “Switch currency”.
Select updates cookie and auto-reloads.
(You already shipped a styled version; keep it accessible and tiny.)
Environment variables
If you later plug in paid providers:
# .env
CURRENCYLAYER_KEY=…
FIXER_KEY=…
EXCHANGERATE_API_KEY=… # if you move to their paid endpoint
Keep your /api/rates
route aware of these and conditionally enable those branches.
Testing the hard parts
- Normalization: Unit test
normalizeLegacyCode
with inputs from your static JSON. - Route: Integration test
/api/rates?base=USD&target=NGN
with mocked provider responses (success/timeout/invalid). - Formatting: Snapshot tests for different currencies (e.g., JPY 0 decimals, TND 3 decimals).
- Browser: E2E test the “Switch currency” flow (cookie set + auto reload + updated price).
Known edge cases & mitigations
- Provider outage → You get
rate=1
fallback. Consider showing USD with a small “estimate” badge when fallback triggers repeatedly. - Extremely volatile FX (some markets) → shorten revalidate windows and snapshot at checkout.
- Rounding disagreements (gateway vs. display) → ensure you and the gateway apply the same rounding rules on the same base amount.
What I learned (as a non-expert)
- “Small UX niceties” require real engineering to be reliable.
- Having both client & server logs made debugging straightforward.
- A switcher isn’t awkward—it’s respectful. Let users correct you.
- Auditable snapshots at checkout prevent all sorts of support headaches later.
Open questions for the community
- Which FX provider do you trust most for production?
- Do you show “Last updated (Source)” in the UI?
- Have you had issues with specific currencies’ minor units?
- Best practices you use to avoid SSR/hydration price flicker?
Wrap-up
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.
If you want the full code I used, I’m happy to share snippets or a template repo. And if you’ve solved this better, I’d love to learn from you. 🙏
Happy shipping.