The "I want a USD/JPY 5-year chart on my page, no backend, no API key" target turns out to be reachable with one stack: Frankfurter (a free, CORS-enabled wrapper around the ECB's official reference rates) for the data, and a hand-rolled SVG
<path>for the chart. ~350 lines of vanilla JS, 20 unit tests, ECB data updates daily. This walks through the four pieces that aren't obvious: choosing Frankfurter over the alternatives, pivoting the response into per-symbol series, the "nice numbers" algorithm for y-axis ticks, and the SVG y-flip via inverted range.
🌐 Demo: https://sen.ltd/portfolio/forex-history/
📦 GitHub: https://github.com/sen-ltd/forex-history
Why Frankfurter and not the other FX APIs
The free FX API space is smaller than you'd think once you require "browser-only, no server, no auth, CORS":
-
ECB direct (
www.ecb.europa.eu): XML/CSV, no CORS — needs a server proxy. - Yahoo Finance: unofficial, no CORS, occasionally blocks bots.
- Alpha Vantage: free tier capped at 25 requests / day, requires an API key.
- OpenExchangeRates: 1000 requests / month free, requires an API key.
-
Frankfurter (
api.frankfurter.dev): CORS, no auth, JSON, commercial use explicitly allowed.
Frankfurter is a small open-source project that re-serves the ECB's daily reference rates with permissive CORS headers. It updates after ~3 PM CET on ECB business days. Endpoint shape:
https://api.frankfurter.dev/v1/2021-01-01..2026-01-01?base=USD&symbols=JPY,EUR,GBP
For a "USD/JPY chart on a static page" use case, this is the answer.
Pivoting the response
Frankfurter's response is keyed by date, then by currency:
{
"base": "USD",
"rates": {
"2021-01-04": { "JPY": 103.55, "EUR": 0.815, "GBP": 0.731 },
"2021-01-05": { "JPY": 103.20, "EUR": 0.812, "GBP": 0.730 }
}
}
Charts want per-symbol time series, so pivot it:
export function parseFrankfurterResponse(json) {
if (!json || !json.rates) return null;
const dates = Object.keys(json.rates).sort();
const symbols = new Set();
for (const d of dates) for (const s of Object.keys(json.rates[d])) symbols.add(s);
const series = {};
for (const sym of symbols) series[sym] = [];
for (const date of dates) {
for (const sym of symbols) {
const v = json.rates[date]?.[sym];
if (typeof v === "number" && Number.isFinite(v)) {
series[sym].push({ date, value: v });
}
}
}
return { base: json.base, symbols: [...symbols].sort(), series };
}
Three details:
-
Object.keys(...).sort()because JavaScript object key order isn't reliable across engines for non-integer keys. Sorting explicitly makes the chart deterministic. -
Number.isFinite(v)dropsnull(sometimes appears for holidays Frankfurter didn't fill in) andNaN. Without this, the line chart drops to y=0 wherever a hole exists. - Symbols are collected by union, not assumed from the first row, so a series that only appears on some dates still gets a (sparse) entry.
test("parseFrankfurterResponse drops missing values inside a row", () => {
const json = { base: "USD", rates: {
"2021-01-04": { JPY: 103.0 },
"2021-01-05": { JPY: null, EUR: 0.815 },
}};
const out = parseFrankfurterResponse(json);
assert.equal(out.series.JPY.length, 1); // null dropped
assert.equal(out.series.EUR.length, 1);
});
Y-axis ticks: the Heckbert "Nice Numbers" algorithm
A line chart with y-ticks at 127.34, 137.49, 147.65 is correct but unreadable. The classic fix is rounding to 1, 2, or 5 times a power of 10:
export function niceTicks(min, max, targetCount = 5) {
if (min === max) return [min];
if (min > max) [min, max] = [max, min];
const step = niceStep((max - min) / targetCount);
const first = Math.ceil(min / step) * step;
const out = [];
for (let v = first; v <= max + step * 1e-9; v += step) {
out.push(Number(v.toFixed(10)));
}
return out;
}
function niceStep(rough) {
const exp = Math.floor(Math.log10(rough));
const f = rough / Math.pow(10, exp);
let nice;
if (f < 1.5) nice = 1;
else if (f < 3) nice = 2;
else if (f < 7) nice = 5;
else nice = 10;
return nice * Math.pow(10, exp);
}
Two boring-but-important corrections:
-
+ step * 1e-9in the loop bound. Without it, floating-point accumulation drops the last tick. Withmax=100, step=20,5 * 20should be exactly 100, but it isn't —0 + 20 + 20 + 20 + 20 + 20 ≈ 99.99999...in binary float. The epsilon makes 100 land on the tick. -
Number(v.toFixed(10))strips trailing zero artefacts.0.1 + 0.2in JavaScript is0.30000000000000004; calling.toFixed(10)thenNumber(...)yields the clean0.3.
test("niceTicks chooses 1/2/5 × 10^n step sizes", () => {
assert.deepEqual(niceTicks(0, 10, 5), [0, 2, 4, 6, 8, 10]);
assert.deepEqual(niceTicks(0, 100, 5), [0, 20, 40, 60, 80, 100]);
});
This is the same algorithm D3 uses internally for axis.tickArguments() defaults; it's the algorithm worth memorising if you're going to draw axis ticks more than once.
SVG <path d> from a series
The path is one string per series. Generate it in a unit-free way by parameterising the x/y scalers:
export function buildLinePath(series, xFn, yFn) {
if (!series.length) return "";
if (series.length === 1) {
const { x, y } = xyOf(series[0], xFn, yFn);
return `M ${fmt(x)} ${fmt(y)} l 0.01 0`; // single-point stub
}
const first = xyOf(series[0], xFn, yFn);
let d = `M ${fmt(first.x)} ${fmt(first.y)}`;
for (let i = 1; i < series.length; i++) {
const { x, y } = xyOf(series[i], xFn, yFn);
d += ` L ${fmt(x)} ${fmt(y)}`;
}
return d;
}
function fmt(n) {
return Number(n.toFixed(3)).toString();
}
Three calls worth making:
-
xFn / yFn are arguments, not closed-over globals. The caller composes them with
linearScale(...); the path generator stays pure. -
Single-point series get a
l 0.01 0stub — a 1/100 pixel relative line, so astroke-linecap: rounddot still shows up even when the user picks a date range with only one ECB business day in it. - 3-decimal rounding. Sub-pixel precision isn't visible on any realistic display, and on 1,000-point series this saves ~15% bytes in the output SVG.
Inverted-range scale for the SVG y-flip
SVG's y axis grows downward (DOM convention). Charts want y to grow upward. The simple fix is to feed the y scaler an inverted range:
// Top of plot area gets the max value; bottom gets the min.
const yFn = (v) => linearScale(v, yMin, yMax, PAD_TOP + PLOT_H, PAD_TOP);
For this to work, linearScale must not assume rangeMin < rangeMax:
export function linearScale(value, domainMin, domainMax, rangeMin, rangeMax) {
if (domainMax === domainMin) return (rangeMin + rangeMax) / 2;
const t = (value - domainMin) / (domainMax - domainMin);
return rangeMin + t * (rangeMax - rangeMin);
}
test("linearScale handles inverted ranges (for y-axis flipping)", () => {
assert.equal(linearScale(0, 0, 10, 100, 0), 100);
assert.equal(linearScale(10, 0, 10, 100, 0), 0);
});
That's the entire y-flip. No separate invert flag, no special-case math.
The unavoidable multi-currency scale problem
Look at the screenshot: USD/JPY trends from 109 to 158, while USD/EUR sits at 0.8 and USD/GBP at 0.7. On a shared y-axis the EUR/GBP/CNY lines look almost flat — the JPY's 50-unit movement dominates the 0.1-unit movements of the others.
Two standard fixes:
- Index to 100: rescale each series so its first point = 100. The chart shows percent change and every series sits in the same range.
- Dual y-axes: a left axis for the dominant series, a right axis for the others. Bloomberg-style. Confuses some readers; clear once you label it.
I shipped the naive version because the article is about getting the data and drawing it; the index-to-100 option is a linearScale away if you want it.
TL;DR
- Frankfurter (https://api.frankfurter.dev/) is the right free FX API for browser-only use: CORS, no auth, JSON, ECB data.
- The response is
date → currency → rate; pivot tocurrency → [{date, value}]for charting. - Drop
null/ non-finite values during the pivot or your line chart will dive to zero on ECB holidays. - Use Heckbert's 1/2/5 × 10^n algorithm for y-axis ticks; remember the floating-point epsilon at the loop bound and the
toFixed(10)strip for clean labels. - Pass
xFn/yFninto the path generator so it stays unit-free; round to 3 decimals for compact SVG. - Flip the SVG y axis by passing an inverted range to
linearScale— no special-case code needed.
Source: https://github.com/sen-ltd/forex-history — MIT, ~350 lines of JS, 20 unit tests, no build step, zero runtime dependencies.
🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)