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 NextJs. If you want your pricing page to feel local and behave reliably in production, this is for you.

TL;DR


Why this matters (even for small teams)

“Show local prices” sounds trivial—until you meet:

  1. Legacy ISO codes in country lists (Morocco’s MAF showed up in my data; modern code is MAD).
  2. Provider churn (an endpoint you used yesterday starts demanding an API key).
  3. Hydration & SSR quirks (flicker vs. gating while detecting currency).
  4. Auditability (what rate did we use at the time of purchase?).
  5. User agency (let them switch if you guessed wrong).

Treat this as a minimum viable reliability pattern you can adapt.


Design goals

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

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:

Client caching strategy


Server route: multi-provider FX fallback

I use a Next.js App Router route.js with a chain:

  1. exchangerate.host (/convert then /latest)
  2. open.er-api.com (/v6/latest/:BASE)
  3. fawazahmed0 via jsDelivr (static daily files)
  4. 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?


Caching, hydration, and flicker


UI: the “Switch currency” 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:


Rounding and minor units

A tiny fallback symbol map helps when Intl doesn’t recognize a code.


Observability & debugging

Log both sides:

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

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}; }
  1. 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.)

  1. “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


Known edge cases & mitigations


What I learned (as a non-expert)


Open questions for the community


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:

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.