Tutorial · JavaScript

Currency converter in JavaScript and Node.js

Fetch exchange rates on the server, convert amounts without floating-point errors, round to each currency's decimals and format the result with Intl.NumberFormat.

Updated October 29, 2026 · 10 min read

To build a currency converter in JavaScript, fetch exchange rates on your server (Node.js), cache them, and send only the rates to the browser. Convert amounts with integer arithmetic (BigInt) instead of floating-point numbers, round once to the target currency’s minor units, and format the result with Intl.NumberFormat. This guide builds that in five small files, with plain fetch and the currencyapi /v3/latest endpoint. To convert single amounts without your own rate math, the currency converter API does it in one call with /v3/convert (from the Small plan).

All code runs on Node.js 22 or later and in current browsers. It was tested with Node.js 26, Express 5 and headless Chromium against a local server that answers with the examples from the API documentation.

Before you start

  1. Create a free account and copy your API key.
  2. Keep the key in an environment variable on the server, never in front-end code or Git:
export CURRENCYAPI_KEY="your-api-key"
npm install express
EndpointWhat you getPlans
/v3/latestLatest rates for any base currencyAll
/v3/historicalEnd-of-day rates for past dates, back to 1999All
/v3/convertConverted amounts, calculated by the APISmall and up

The Free plan is for testing and personal projects with 300 requests a month; a converter on a commercial site needs a paid plan (pricing).

1. Call the API from Node.js

One function sends every request, with the key in the apikey header (the recommended way), a timeout, and an error that carries the API’s error code:

// currencyapi.js — server only: the API key never leaves this file
export class CurrencyApiError extends Error {
  constructor(status, code, message, errors) {
    super(`currencyapi ${status} (${code}): ${message}`);
    this.status = status;
    this.code = code;
    this.errors = errors; // 422: which parameter is wrong, and why
  }
}

export async function currencyApi(endpoint, params = {}) {
  const url = new URL(`https://api.currencyapi.com/v3/${endpoint}`);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);

  const res = await fetch(url, {
    headers: { apikey: process.env.CURRENCYAPI_KEY },
    signal: AbortSignal.timeout(10_000),
  });
  const body = await res.json().catch(() => null);
  if (!res.ok) {
    const code = body?.error?.code ?? (res.status === 422 ? "validation_error" : "error");
    throw new CurrencyApiError(res.status, code, body?.message ?? res.statusText, body?.errors);
  }
  return body;
}

// { EUR: { code: "EUR", value: 0.918456 }, … } → { USD: "1", EUR: "0.918456", … }
export function toRateStrings(data, base) {
  const rates = {};
  for (const { code, value } of Object.values(data)) rates[code] = String(value);
  rates[base] = "1"; // the base itself, also when you filter with `currencies`
  return rates;
}

toRateStrings keeps every rate as a string. String(0.918456) gives back "0.918456", the decimal the API sent, which the conversion step below reads digit by digit.

The errors you will see in practice (all status codes):

Statuserror.codeMeaning
401missing_api_key, invalid_api_keyNo key sent, or the key is wrong
403plan_upgrade_requiredThe endpoint or parameter isn’t in your plan, e.g. /v3/convert on Free
422—A parameter is invalid; errors names it
429quota_exceeded, rate_limit_exceededMonthly quota used up, or more than 10 requests a minute on Free

Errors and validation failures don’t count against your quota (rate limits).

2. Cache the rates on the server

Your visitors shouldn’t each trigger an API request. Fetch all currencies once against one base and reuse them until the next update:

// rates.js — one API request per hour, however many visitors convert
import { currencyApi, toRateStrings } from "./currencyapi.js";

const TTL_MS = 60 * 60 * 1000; // Free plan: daily updates, Small: hourly, Pro: every 15 min, Medium and up: every 60 s
let cached = null; // { base, rates, updatedAt, fetchedAt }
let pending = null; // the request in flight, shared by concurrent callers

export async function getRates() {
  if (cached && Date.now() - cached.fetchedAt < TTL_MS) return cached;

  pending ??= currencyApi("latest", { base_currency: "USD" })
    .then((body) => {
      cached = {
        base: "USD",
        rates: toRateStrings(body.data, "USD"),
        updatedAt: body.meta.last_updated_at,
        fetchedAt: Date.now(),
      };
      return cached;
    })
    .catch((err) => {
      if (cached) return cached; // API unreachable or quota used up: keep the last rates
      throw err;
    })
    .finally(() => {
      pending = null;
    });
  return pending;
}

// End-of-day rates for up to 31 past dates in one call; each date counts as one request
export async function historicalRates(dates, base = "USD") {
  const body = await currencyApi("historical", { dates: dates.join(","), base_currency: base });
  return Object.fromEntries(
    Object.entries(body.data).map(([date, data]) => [date, toRateStrings(data, base)]),
  );
}

Three details matter here:

Use historicalRates for orders, invoices and refunds of past days: convert at the rate of the transaction date and store that rate with the result.

3. Convert without floating-point errors

JavaScript numbers are binary floating point, and money suffers from it:

10 * 1.0005; // 10.004999999999999, not 10.005
Math.round(10 * 1.0005 * 100) / 100; // 10, commercial rounding gives 10.01
(10 * 1.0005).toFixed(2); // "10.00"

The fix is to compute in whole numbers. money.js reads amounts and rates as decimal strings into BigInt, does the whole calculation (amount ÷ rate of the source currency × rate of the target currency) exactly, and rounds once at the end:

// money.js — exact conversion with BigInt, for Node.js and the browser

// "0.918456" → { units: 918456n, scale: 6 }; also reads "8.8123e-6"
export function parseDecimal(text) {
  const match = /^([+-]?)(\d+)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(String(text).trim());
  if (!match) throw new TypeError(`Not a decimal number: ${text}`);
  const [, sign, int, frac = "", exp = "0"] = match;
  let units = BigInt(sign + int + frac);
  let scale = frac.length - Number(exp);
  if (scale < 0) {
    units *= 10n ** BigInt(-scale);
    scale = 0;
  }
  return { units, scale };
}

// numerator / denominator rounded to a whole number:
// "halfExpand" rounds .5 away from zero, "halfEven" to the even neighbour (banker's rounding)
export function divideRounded(numerator, denominator, mode = "halfExpand") {
  if (denominator < 0n) [numerator, denominator] = [-numerator, -denominator];
  const quotient = numerator / denominator; // BigInt division truncates toward zero
  const remainder = numerator % denominator;
  const twice = 2n * (remainder < 0n ? -remainder : remainder);
  const roundAway =
    twice > denominator ||
    (twice === denominator && (mode === "halfExpand" || quotient % 2n !== 0n));
  return roundAway ? quotient + (numerator < 0n ? -1n : 1n) : quotient;
}

// Decimal places of a currency: EUR 2, JPY 0, KWD 3
export function minorUnits(currency) {
  return new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
    .maximumFractionDigits;
}

// 18368n with 2 decimals → "183.68"
export function formatMinor(minor, digits) {
  const sign = minor < 0n ? "-" : "";
  const text = (minor < 0n ? -minor : minor).toString().padStart(digits + 1, "0");
  return digits ? `${sign}${text.slice(0, -digits)}.${text.slice(-digits)}` : sign + text;
}

// amount (a decimal string) from one currency to another; rates are units per 1 base unit.
// amount ÷ rate(from) × rate(to) is computed exactly and rounded once, to the minor units of `to`.
export function convert(amount, from, to, rates, mode = "halfExpand") {
  if (rates[from] === undefined) throw new RangeError(`No rate for ${from}`);
  if (rates[to] === undefined) throw new RangeError(`No rate for ${to}`);
  const a = parseDecimal(amount);
  const rateFrom = parseDecimal(rates[from]);
  const rateTo = parseDecimal(rates[to]);
  const digits = minorUnits(to);

  const numerator = a.units * rateTo.units * 10n ** BigInt(rateFrom.scale + digits);
  const denominator = rateFrom.units * 10n ** BigInt(a.scale + rateTo.scale);
  return formatMinor(divideRounded(numerator, denominator, mode), digits);
}

With the documentation’s example rates (1 USD = 0.918456 EUR = 143.5627 JPY = 0.307512 KWD):

CallResultWhy
convert("199.99", "USD", "EUR", rates)"183.68"183.68201544, rounded to cents
convert("100", "EUR", "GBP", rates)"85.59"A cross rate via USD, still one rounding step
convert("184.14", "USD", "JPY", rates)"26436"The yen has no minor unit
convert("1000", "USD", "KWD", rates)"307.512"The Kuwaiti dinar has three decimals
convert("10", "USD", "EUR", { USD: "1", EUR: "1.0005" }, "halfEven")"10.00"Banker’s rounding: the tie goes to the even cent

We checked convert against exact fraction arithmetic in Python on 20,000 random amounts, rates and currency pairs, each in both rounding modes, without a single difference.

Pass amounts as strings ("199.99"), straight from your form or database. If you store prices as integer cents, turn them into a string first with formatMinor(BigInt(cents), 2).

4. Round to the currency’s minor units

minorUnits asks the Intl API, which ships with Node.js and every browser, how many decimals a currency uses:

CurrencyDecimalsExample
EUR, USD, GBP2183.68
JPY, KRW026436
KWD, BHD3307.512

Rounding with the same data that Intl.NumberFormat uses for display means the number you store is the number you show. For a few currencies, sources disagree on the decimals; if your accounting follows ISO 4217 to the letter, keep your own table for those.

5. Format the amount for display

Intl.NumberFormat accepts the decimal string directly, so no float sneaks back in:

// example.js
import { getRates } from "./rates.js";
import { convert } from "./money.js";

const { rates, updatedAt } = await getRates();
const eur = convert("199.99", "USD", "EUR", rates);
const text = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(eur);
console.log(eur, text, updatedAt); // 183.68 183,68 € 2023-06-23T10:15:59Z

The locale decides the symbol position, the separators and the spacing: en-US gives €183.68, de-DE gives 183,68 €.

6. Send the rates to the browser

An Express route hands out the cached rates. The browser converts locally, so typing in the amount field needs no request at all:

// server.js — Express 5
import express from "express";
import { getRates } from "./rates.js";

const app = express();
app.use(express.static("public")); // index.html and a copy of money.js

app.get("/api/rates", async (req, res) => {
  try {
    const { base, rates, updatedAt } = await getRates();
    res.set("Cache-Control", "public, max-age=300");
    res.json({ base, rates, updatedAt });
  } catch (err) {
    console.error(err); // e.g. currencyapi 401 (invalid_api_key): Unauthorized
    res.status(503).json({ error: "Exchange rates are unavailable" });
  }
});

app.listen(3000, () => console.log("http://localhost:3000"));

Copy money.js into public/ and add the converter form:

<form id="converter">
  <input name="amount" inputmode="decimal" value="100" aria-label="Amount">
  <select name="from" aria-label="From"><option>USD</option><option>EUR</option><option>GBP</option><option>JPY</option></select>
  <select name="to" aria-label="To"><option>EUR</option><option>USD</option><option>GBP</option><option>JPY</option></select>
  <output name="result"></output>
  <small id="updated"></small>
</form>

<script type="module">
  import { convert } from "./money.js";

  const res = await fetch("/api/rates");
  if (!res.ok) throw new Error(`Rates unavailable (${res.status})`);
  const { rates, updatedAt } = await res.json();

  const form = document.querySelector("#converter");
  document.querySelector("#updated").textContent = `Rates of ${updatedAt}`;

  function update() {
    const { amount, from, to, result } = form.elements;
    try {
      const value = convert(amount.value.replace(",", "."), from.value, to.value, rates);
      result.value = new Intl.NumberFormat(navigator.language, {
        style: "currency",
        currency: to.value,
      }).format(value);
    } catch {
      result.value = "–"; // not a number yet, e.g. while typing
    }
  }

  form.addEventListener("input", update);
  update();
</script>

Start it with node server.js and open http://localhost:3000. The catch matters: without it, Express 5 hands the error to its default handler, which answers with the error’s own status, so a wrong API key on your server would reach your visitors as a 401. With it, they get a 503 and the details stay in your server log.

Prefer the API to do the math?

On the Small plan and up, /v3/convert returns converted amounts for one or many target currencies, also for past dates. It saves you the conversion code but costs one request per conversion, while the cached rates above serve any number of conversions from one request. For a price list or a checkout, cache the rates; for a one-off conversion in a back-office job, /v3/convert is simpler.

No code needed?

Embed the free currency converter widget with one line of HTML, convert past amounts with the historical currency converter, or read the currency converter API overview. The full parameter reference is in the API documentation.

Frequently asked questions

Can I call a currency API directly from the browser?

It works, but anyone can then read your API key in the network tab and use up your quota. Call the API from your server or a serverless function, cache the rates there and send only the rates to the browser.

Why not just use Math.round(amount * rate * 100) / 100?

Because JavaScript numbers are binary floating point. 10 * 1.0005 is 10.004999999999999, so Math.round gives 10.00 where commercial rounding gives 10.01. Compute in integer minor units with BigInt and round once at the end.

How many decimals does a currency have?

Ask Intl.NumberFormat: new Intl.NumberFormat('en', { style: 'currency', currency: 'JPY' }).resolvedOptions().maximumFractionDigits returns 0 for the yen, 2 for the euro and 3 for the Kuwaiti dinar. The currency pages list them as well.

How often should my converter fetch new rates?

As often as your plan updates them: daily on the Free plan, hourly on Small, every 15 minutes on Pro and every 60 seconds on Medium and up. Fetching more often only returns the same numbers and uses up requests.

Is there an official JavaScript SDK?

Yes, @everapi/currencyapi-js on npm. This guide uses fetch instead, which needs no dependency and gives you control over timeouts, errors and caching.

Exchange rate API

Get these rates in your app

currencyapi delivers real-time and historical exchange rates for 170+ currencies, crypto and metals as JSON, updated as often as every 60 seconds.

Read the documentation

300 free requests every month. No credit card required.

GET https://api.currencyapi.com/v3/latest?base_currency=USD&currencies=EUR,GBP,JPY

{
  "meta": { "last_updated_at": "2026-09-30T23:59:59Z" },
  "data": {
    "EUR": { "code": "EUR", "value": 0.8828 },
    "GBP": { "code": "GBP", "value": 0.7556 },
    "JPY": { "code": "JPY", "value": 157.98 }
  }
}

Keep reading

Get your free currency API key

Start with 300 free requests a month