1
0 Comments

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 Next.js. 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., 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 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 (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 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();
};

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:

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


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([...]);

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

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.

3) “Switch currency” button

  • Bottom right, labeled “Switch currency”.
  • Select updates cookie and auto-reloads.

Environment variables

If you later plug in paid providers:

# .env
CURRENCYLAYER_KEY=…
FIXER_KEY=…
EXCHANGERATE_API_KEY=…  # if you move to their paid endpoint

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.
  • Formatting: Snapshot tests for different currencies (e.g., JPY 0 decimals, TND 3 decimals).
  • Browser: E2E test the “Switch currency” flow.

Known edge cases & mitigations

  • Provider outage → you get rate=1 fallback. Consider showing USD with an “estimate” badge.
  • Volatile FX → shorten revalidate windows and snapshot at checkout.
  • Rounding disagreements → ensure gateway & display use the same rounding.

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 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. 🙏

on August 26, 2025