8 min read

From “Just Show Local Prices” to a Robust, Auditable Currency System in Next.js

From “Just Show Local Prices” to a Robust, Auditable Currency System in Next.js
From “Just Show Local Prices” to a Robust, Auditable Currency System in Next.js

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

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., MAFMAD, FRF/DEM/ESPEUR).
  • Build a multi-provider FX fallback in a Next.js route: exchangerate.hostopen.er-api.comjsDelivr (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 is MAD).
  • 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 (MAFMAD, FRFEUR, ...)
  • Looks up session-cache FX rate (basetarget)
  • 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/LagosNG).
  • Keep a countries.json with countryCode, 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 (basetarget) 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)

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

Server sends 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}; }

2) 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.)

3) “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.

Happy shipping. 🙏