How to build a currency converter in JavaScript (with live exchange rates)
A currency converter looks like a weekend project: one input, two dropdowns, one multiplication. Whether it survives real traffic depends on everything around it: where rates come from, how often you fetch them, what happens when a fetch fails, and how results read in Tokyo versus Berlin.
This tutorial builds a small, production-shaped converter in plain JavaScript: a Node.js module that fetches live exchange rates and caches them, a tiny HTTP endpoint, and a browser page that formats results with Intl.NumberFormat. No framework, no npm packages, and one API request an hour however many people use it.
What you need
- Node.js 18 or newer, for the built-in
fetchandAbortSignal.timeout(). - A Rate-API key. Create a free account and copy a key from the dashboard. The Free plan includes 2,500 requests a month and 10 a minute, far more than this converter needs.
Put the files below in one folder with a package.json of { "type": "module" } so Node loads them as ES modules, and keep the key in an environment variable, never in source code:
export RATE_API_KEY="your-key-here"
One rate table, many conversions
Calling an API once per conversion is slow and spends your quota on questions you can answer yourself. The /latest endpoint returns every rate against one base currency in a single response, and once you hold that table, converting between any two currencies is arithmetic.
curl -H "X-API-Key: $RATE_API_KEY" \
"https://rate-api.com/api/v1/latest?base=USD"
The shape of the response (values elided):
{
"success": true,
"timestamp": 1790121600,
"base": "USD",
"date": "2026-09-23",
"rates": { "AED": …, "EUR": …, "GBP": …, "JPY": … }
}
successistruewhen the call worked; errors returnfalseplus anerrorobject, covered below.baseis the currency every rate is quoted against: a EUR rate of 0.85 would mean 1 USD buys 0.85 EUR.dateis the date of the rates. Show it next to the result so nobody mistakes a reference rate for a dealing price.ratesmaps currency codes to rates. The base currency itself is not in this map: a USD-based response has no"USD": 1. Forget that, and every conversion from or to USD comes out asNaN.
Pass base explicitly. Without it, the API falls back to the default base currency in your account's API settings, which may not be the one your code assumes.
Keep the key on the server
The API sends CORS headers, so a browser can call it directly. Don't: anything in front-end JavaScript is public, and a copied key spends your quota. The referrer allowlist under Settings → API stops other websites from using your key in a browser, but a script can send any Referer it likes, so it is a speed bump, not a lock. It also covers every key on the account and refuses calls that send no Referer at all, server-side ones included. Put a small server in the middle; it is also where caching belongs.
Step 1: fetch and cache the rates
This module owns every call to the API. It keeps the table in memory for an hour, makes concurrent callers share one in-flight request, and keeps serving the last good table when a refresh fails.
// rates.js - fetch the USD rate table at most once an hour (Node 18+)
const LATEST_URL = 'https://rate-api.com/api/v1/latest?base=USD';
const TTL_MS = 60 * 60 * 1000; // the API refreshes hourly, so do we
const TIMEOUT_MS = 5000;
export class RateApiError extends Error {
constructor(message, { status, type, retryAfter } = {}) {
super(message);
this.name = 'RateApiError';
this.status = status; // HTTP status, e.g. 401 or 429
this.type = type; // the API's machine-readable error.type
this.retryAfter = retryAfter; // seconds, when the API sent Retry-After
}
}
let table = null; // { rates, date, fetchedAt }
let inFlight = null; // one shared request while a refresh runs
let retryAt = 0; // after a failure, don't call again before this
async function fetchTable() {
const res = await fetch(LATEST_URL, {
headers: { 'X-API-Key': process.env.RATE_API_KEY ?? '' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.success !== true) {
const retryAfter = Number(res.headers.get('Retry-After')); // NaN when absent
throw new RateApiError(body?.error?.message ?? `HTTP ${res.status}`, {
status: res.status,
type: body?.error?.type,
retryAfter: retryAfter > 0 ? retryAfter : undefined,
});
}
// The base currency is not in its own rates map, so add it as 1.
return {
rates: { ...body.rates, [body.base]: 1 },
date: body.date,
fetchedAt: Date.now(),
};
}
export async function getRates() {
const now = Date.now();
if (table && (now - table.fetchedAt < TTL_MS || now < retryAt)) return table;
if (now < retryAt) throw new RateApiError('Rates unavailable, retrying later', { status: 503 });
inFlight ??= fetchTable().finally(() => {
inFlight = null;
});
try {
table = await inFlight;
return table;
} catch (err) {
retryAt = Date.now() + (err.retryAfter ?? 60) * 1000;
if (table) return table; // an hour-old rate beats an error page
throw err;
}
}
- A one-hour TTL. Rate-API refreshes its rates hourly, so fetching more often buys nothing. That is 24 requests a day, about 744 in a 31-day month, well inside the Free plan's 2,500.
- One in-flight request. A hundred visitors arriving as the cache expires share one promise, not a hundred requests.
- Stale beats broken. An hour-old reference rate is fine for a converter; an error page is not.
- Back off after a failure.
retryAtstops a failing refresh from being retried on every incoming request. It waits forRetry-Afterwhen the API sends one, otherwise 60 seconds.
Step 2: convert between any two currencies
Every rate means "units of this currency per 1 USD", so a conversion goes from the source currency to USD, then from USD to the target:
// convert.js - pure helpers, no I/O
export function convert(amount, from, to, rates) {
const fromRate = rates[from];
const toRate = rates[to];
if (!(fromRate > 0)) throw new RangeError(`Unsupported currency: ${from}`);
if (!(toRate > 0)) throw new RangeError(`Unsupported currency: ${to}`);
// Rates are "units per 1 USD": go amount -> USD -> target.
return (amount / fromRate) * toRate;
}
const formatters = new Map();
export function formatMoney(value, currency, locale = 'en-US') {
const key = `${locale}|${currency}`;
if (!formatters.has(key)) {
formatters.set(key, new Intl.NumberFormat(locale, { style: 'currency', currency }));
}
return formatters.get(key).format(value);
}
Because getRates() added USD as 1, the base needs no special case. Unknown codes throw a RangeError instead of quietly producing NaN or, worse, pretending the rate is 1. And convert() deliberately doesn't round: that depends on the currency (the yen has no minor unit, the Kuwaiti dinar has three), which is exactly what Intl.NumberFormat knows.
Step 3: format money with Intl.NumberFormat
With style: 'currency', the platform's formatter picks the symbol, its position, the separators and the number of decimals for the locale and currency you pass:
formatMoney(1234.5, 'EUR', 'de-DE'); // "1.234,50 €"
formatMoney(1234.5, 'JPY', 'ja-JP'); // "¥1,235"
formatMoney(1234.5, 'KWD', 'en-US'); // "KWD 1,234.500"
formatMoney(123456.5, 'INR', 'en-IN'); // "₹1,23,456.50"
- Cache the formatter. Constructing one costs far more than calling
format(). - Locale and currency are separate choices. A German reader looking at US dollars wants
"1.234,50 $". Take the locale from the visitor and the currency from the conversion.
Step 4: a small HTTP endpoint
The server serves the page plus two JSON endpoints: /api/convert for one formatted conversion and /api/currencies for the dropdowns. It uses only Node's built-in http module:
// server.js - the page, plus two small JSON endpoints backed by the cached table
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { getRates } from './rates.js';
import { convert, formatMoney } from './convert.js';
const CODE = /^[A-Z]{3}$/;
function send(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(await readFile(new URL('./index.html', import.meta.url)));
}
if (url.pathname === '/api/currencies') {
const { rates, date } = await getRates();
return send(res, 200, { currencies: Object.keys(rates).sort(), date });
}
if (url.pathname === '/api/convert') {
const amount = Number(url.searchParams.get('amount'));
const from = (url.searchParams.get('from') ?? '').toUpperCase();
const to = (url.searchParams.get('to') ?? '').toUpperCase();
const locale = url.searchParams.get('locale') || 'en-US';
if (!Number.isFinite(amount) || amount < 0 || !CODE.test(from) || !CODE.test(to)) {
return send(res, 400, { error: 'Expected ?amount=100&from=USD&to=EUR' });
}
const { rates, date } = await getRates();
const result = convert(amount, from, to, rates);
return send(res, 200, {
result,
formatted: formatMoney(result, to, locale),
rate: convert(1, from, to, rates),
date,
});
}
send(res, 404, { error: 'Not found' });
}
createServer((req, res) => {
handle(req, res).catch((err) => {
if (err instanceof RangeError) return send(res, 400, { error: err.message });
console.error(err); // status + type are in here; keep them in your logs
send(res, 503, { error: 'Exchange rates are unavailable right now' });
});
}).listen(3000, () => console.log('Converter on http://localhost:3000'));
A code that looks valid but isn't in the table gets a 400 from the RangeError in convert(). If the API is unreachable and nothing is cached yet, visitors get a plain 503; the HTTP status and error.type go to your logs, never to the page. Returning one computed result, not the whole table, keeps responses small and keeps the raw data inside your product rather than republishing it.
Step 5: the browser page
The front end needs no library. It fills the dropdowns once, then asks your server for a conversion after each pause in typing:
<!doctype html>
<meta charset="utf-8">
<title>Currency converter</title>
<form id="converter">
<input id="amount" type="number" min="0" step="any" value="100" aria-label="Amount">
<select id="from" aria-label="From currency"></select>
<select id="to" aria-label="To currency"></select>
</form>
<p><output id="result"></output></p>
<p><small id="asof"></small></p>
<script type="module">
const $ = (id) => document.getElementById(id);
const names = new Intl.DisplayNames([navigator.language], { type: 'currency' });
let controller;
let timer;
async function update() {
controller?.abort(); // drop the answer to a question nobody is asking any more
controller = new AbortController();
const params = new URLSearchParams({
amount: $('amount').value || '0',
from: $('from').value,
to: $('to').value,
locale: navigator.language,
});
try {
const res = await fetch(`/api/convert?${params}`, { signal: controller.signal });
const data = await res.json();
$('result').textContent = res.ok ? data.formatted : data.error;
if (res.ok) $('asof').textContent = `Rates as of ${data.date}`;
} catch (err) {
if (err.name !== 'AbortError') $('result').textContent = 'Could not reach the server.';
}
}
try {
const { currencies } = await fetch('/api/currencies').then((r) => r.json());
for (const select of [$('from'), $('to')]) {
select.append(...currencies.map((code) => new Option(`${code} - ${names.of(code)}`, code)));
}
$('from').value = 'USD';
$('to').value = 'EUR';
$('converter').addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(update, 250); // wait for a pause in typing
});
update();
} catch {
$('result').textContent = 'Currency list unavailable, try again shortly.';
}
</script>
Intl.DisplayNames turns "EUR" into "Euro" in English or "евро" in Russian without shipping a list of names. AbortController cancels the previous request when a new one starts, so a slow answer for "10" can never overwrite the answer for "100". Those requests go to your server, which answers from memory; the API still sees one request an hour. Start it with node server.js and open http://localhost:3000.
Errors and rate limits
Every Rate-API error uses the same envelope, so one code path handles them all:
{
"success": false,
"error": {
"code": 429,
"type": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 7 seconds."
},
"api_version": "v1",
"request_id": "5c473bf6-2ec9-48a4-9fc4-debb61a11c0b",
"timestamp": 1790112727
}
Branch on error.type, never on the message text:
missing_api_keyorinvalid_api_key(401): the variable is unset or the key was revoked. Retrying won't help; alert a human.invalid_base_currencyorinvalid_symbol(400): a code the API doesn't recognise.ip_not_allowedorreferrer_not_allowed(403): the call came from outside an allowlist you configured.rate_limit_exceeded(429): too many requests this minute. Wait the number of seconds in theRetry-Afterheader.quota_exceeded(429): the monthly quota is used up and only resets at the start of next month, so waiting a minute won't help. Serve cached data or upgrade.
Per-minute limits are 10 on Free, 100 on Pro and 1,000 on Business and Enterprise, shared by every key on the account, so a second key doesn't double them. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp), and GET /api/v1/quota reports monthly usage. Quote the request_id (also sent as X-Request-Id) when contacting support. With the hourly cache, this converter should never see a 429; if it does, something is calling the API outside getRates().
Where to go from here
- Server-side conversion. On paid plans,
/convert?from=EUR&to=JPY&amount=100returns a converted amount directly; Free keys get a 403 withfeature_not_available. The table approach above works on every plan. See pricing for what each plan includes. - Check your numbers against the converter pages, such as USD to EUR or EUR to JPY.
- No code at all? The integrations page has a drop-in converter widget, a WordPress block and more.
Every endpoint, parameter and response field is in the API reference. Get a free API key and the converter above runs in a few minutes.