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
- Create a free account and copy your API key.
- 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
| Endpoint | What you get | Plans |
|---|---|---|
/v3/latest | Latest rates for any base currency | All |
/v3/historical | End-of-day rates for past dates, back to 1999 | All |
/v3/convert | Converted amounts, calculated by the API | Small 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):
| Status | error.code | Meaning |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | No key sent, or the key is wrong |
| 403 | plan_upgrade_required | The endpoint or parameter isn’t in your plan, e.g. /v3/convert on Free |
| 422 | — | A parameter is invalid; errors names it |
| 429 | quota_exceeded, rate_limit_exceeded | Monthly 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:
- Concurrent requests share one fetch. Ten visitors arriving at the same moment after the cache expired cause one API call, not ten.
- Stale beats broken. If the API can’t be reached, the converter keeps working with the last rates, and
updatedAttells your users how old they are. - One base, all currencies. With USD-based rates you can convert between any two currencies, so one request covers every pair.
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):
| Call | Result | Why |
|---|---|---|
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:
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.