Worked examples you can view-source
Not snippets — 3 finished files: 2HTML pages that run in your reader's browser, and 1 Node server that answers before the page is sent. No build step, no framework, no bundler and no API key: open one, read it top to bottom, save it, change the numbers. Together they are 31.2 KB. The fourth is a sketch, not a file — the server example as a Next.js route handler, which is where the cache stops being a Map.
What these pages are allowed to assume about you: nothing. There is no key to request, no account to create, no signup, no quota tier and no billing. The public API answers 60 requests a minute per IP, from any origin, and the examples below show the three habits that keep a live-as-you-type page comfortably inside that. The whole thing is MIT licensed — the examples included, so you can paste one into your own repository without asking.
A mortgage payment, as you type
The whole thing in one file: four inputs, a live monthly payment, and the three habits that keep a live-as-you-type page inside a 60-request-a-minute budget.
- Debounce, cache and abort — the three lines that stop a keystroke-driven page from spending its rate limit.
- cw.url() builds the request without sending it, which makes it a cache key.
- Errors arrive as a CalcWiseError carrying the API's own per-parameter explanation, so you can show it instead of “something went wrong”.
To run it: Open the file in a browser.
The whole file
<!doctype html>
<!-- mortgage.html: a worked example for the free Far Better Off finance API.
MIT licensed. Take it, change it, ship it. https://farbetteroff.com/examples -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mortgage payment — a Far Better Off API example</title>
<meta name="description" content="A complete, dependency-free HTML page that calls the free Far Better Off finance API and shows a monthly mortgage payment as you type.">
<link rel="canonical" href="https://farbetteroff.com/examples">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --card: #f8fafc; --ink: #0f172a; --dim: #64748b;
--line: #e2e8f0; --accent: #047857; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a; --card: #1e293b; --ink: #e2e8f0; --dim: #94a3b8;
--line: #334155; --accent: #34d399; --bad: #fca5a5;
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--ink);
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 32rem; margin: 0 auto; }
h1 { font-size: 1.35rem; line-height: 1.25; margin: 0 0 0.5rem; }
p.lede { margin: 0 0 1.5rem; color: var(--dim); font-size: 0.9rem; }
label { display: block; margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--dim); }
input, select {
width: 100%; margin-top: 0.25rem; padding: 0.6rem 0.7rem; font: inherit;
color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 0.5rem;
}
input:focus-visible, select:focus-visible, button:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
#answer {
margin-top: 1.25rem; padding: 1rem 1.15rem; background: var(--card);
border: 1px solid var(--line); border-radius: 0.75rem;
}
.big { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }
.small { margin-top: 0.4rem; font-size: 0.8rem; color: var(--dim); }
.bad { color: var(--bad); font-size: 1.05rem; font-weight: 600; }
footer {
margin-top: 1.5rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
font-size: 0.8rem; color: var(--dim);
}
a { color: var(--accent); }
code { font-size: 0.9em; }
</style>
</head>
<body>
<main>
<h1>Monthly mortgage payment</h1>
<p class="lede">
One HTML file — no build step, no framework, no API key. The math comes
from the free <a href="https://farbetteroff.com/api">Far Better Off API</a>.
</p>
<form id="form">
<label>Home price
<input name="homePrice" type="number" inputmode="numeric" min="1000" max="100000000" step="1000" value="400000">
</label>
<label>Down payment
<input name="downPayment" type="number" inputmode="numeric" min="0" step="1000" value="80000">
</label>
<label>Interest rate (%)
<input name="rate" type="number" inputmode="decimal" min="0.01" max="25" step="0.05" value="6.5">
</label>
<label>Term (years)
<input name="termYears" type="number" inputmode="numeric" min="1" max="50" step="1" value="30">
</label>
</form>
<div id="answer" aria-live="polite">
<div id="total" class="big">…</div>
<div id="detail" class="small"></div>
</div>
<noscript>
<p class="small">This example is a JavaScript demo of the API. The calculator
itself needs no JavaScript to give you an answer:
<a href="https://farbetteroff.com/calculators/mortgage-calculator">try it on Far Better Off</a>.</p>
</noscript>
<footer>
Numbers by <a href="https://farbetteroff.com">Far Better Off</a> — free, no key, no signup, no ads.
<a href="https://farbetteroff.com/examples">Read this page's source</a>, or
<a href="https://farbetteroff.com/examples/mortgage.html" download>save the file</a>.
</footer>
</main>
<script type="module">
// The client is one file. Import it from Far Better Off, as here, or keep your own
// copy next to this page and never depend on us being up:
//
// curl -O https://farbetteroff.com/calcwise.js
// import { calcwise } from "./calcwise.js";
//
// TypeScript project? https://farbetteroff.com/calcwise.d.ts sits beside it and
// types all 28 endpoints with no import and no tsconfig change.
import { calcwise, CalcWiseError } from "https://farbetteroff.com/calcwise.js";
const cw = calcwise();
const form = document.getElementById("form");
const totalEl = document.getElementById("total");
const detailEl = document.getElementById("detail");
const usd = new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD", maximumFractionDigits: 0
});
// The public API allows 60 requests a minute per IP, which is plenty for a
// page like this -- as long as it does not fire one per keystroke. Three
// things keep it well inside the budget, and they are the only part of this
// file worth copying:
//
// 1. debounce -- wait for a pause in typing before asking anything
// 2. cache -- never ask the same question twice
// 3. abort -- the answer to the previous keystroke is already stale
const seen = new Map();
let inFlight = null;
let debounce = null;
function readForm() {
const num = (name) => Number(form.elements[name].value);
return {
homePrice: num("homePrice"),
downPayment: num("downPayment"),
rate: num("rate"),
termYears: num("termYears")
};
}
async function update() {
const params = readForm();
// cw.url() builds the request URL without sending it, so it doubles as a
// cache key that is exactly as specific as the request.
const key = cw.url("mortgage", params);
if (seen.has(key)) { show(seen.get(key)); return; }
if (inFlight) inFlight.abort();
inFlight = new AbortController();
try {
const res = await cw.mortgage(params, { signal: inFlight.signal });
seen.set(key, res);
show(res);
} catch (err) {
if (err.name === "AbortError") return; // superseded, not failed
showError(err);
}
}
function show(res) {
const r = res.result;
const bits = [
usd.format(r.loanAmount) + " borrowed over " + r.payoffTermLabel,
"principal and interest " + usd.format(r.principalAndInterest)
];
// A down payment under 20% adds PMI, so the total is not just P&I.
if (r.breakdown.pmi) bits.push("PMI " + usd.format(r.breakdown.pmi));
bits.push("property tax and insurance not included");
totalEl.className = "big";
totalEl.textContent = usd.format(r.monthlyTotal) + " / month";
detailEl.textContent = bits.join(" · ");
}
function showError(err) {
totalEl.className = "bad";
detailEl.textContent = "";
if (err instanceof CalcWiseError && err.issues.length) {
// The API names every bad parameter and says what it expected, all in
// one response. Pass that through rather than flattening it to "400".
totalEl.textContent = err.issues
.map((issue) => issue.param + ": " + issue.message)
.join(" ");
} else if (err instanceof CalcWiseError && err.status === 429) {
totalEl.textContent = "Too many requests. Try again in " +
(err.retryAfter || 60) + " seconds.";
} else {
totalEl.textContent = "Could not reach Far Better Off: " + err.message;
}
}
form.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(update, 300);
});
update();
</script>
</body>
</html>
Three answers, and one of them is “no answer”
State income tax on a year of wages. The interesting branch is the third one: for the 30 states Far Better Off does not model, the API returns null and a reason, and rendering that as $0 would put a wrong number on your page.
- A null that means “no answer”, never zero — and a UI that says so out loud.
- Every answer carries its own source (publisher and URL); passing that through costs four lines.
- The select is generated from the API's own enum, so it cannot drift out of step with what the endpoint accepts.
To run it: Open the file in a browser.
The whole file
<!doctype html>
<!-- state-tax.html: a worked example for the free Far Better Off finance API.
MIT licensed. Take it, change it, ship it. https://farbetteroff.com/examples -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>State income tax on wages — a Far Better Off API example</title>
<meta name="description" content="A complete, dependency-free HTML page showing how the free Far Better Off API answers state income tax three ways: zero, a figure, or an explicit null with a reason.">
<link rel="canonical" href="https://farbetteroff.com/examples">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --card: #f8fafc; --ink: #0f172a; --dim: #64748b;
--line: #e2e8f0; --accent: #047857; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a; --card: #1e293b; --ink: #e2e8f0; --dim: #94a3b8;
--line: #334155; --accent: #34d399; --bad: #fca5a5;
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--ink);
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 32rem; margin: 0 auto; }
h1 { font-size: 1.35rem; line-height: 1.25; margin: 0 0 0.5rem; }
p.lede { margin: 0 0 1.5rem; color: var(--dim); font-size: 0.9rem; }
label { display: block; margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--dim); }
input, select {
width: 100%; margin-top: 0.25rem; padding: 0.6rem 0.7rem; font: inherit;
color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 0.5rem;
}
input:focus-visible, select:focus-visible, button:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
#answer {
margin-top: 1.25rem; padding: 1rem 1.15rem; background: var(--card);
border: 1px solid var(--line); border-radius: 0.75rem;
}
.big { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }
.small { margin-top: 0.4rem; font-size: 0.8rem; color: var(--dim); }
.bad { color: var(--bad); font-size: 1.05rem; font-weight: 600; }
footer {
margin-top: 1.5rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
font-size: 0.8rem; color: var(--dim);
}
a { color: var(--accent); }
code { font-size: 0.9em; }
.picks { margin: 0 0 1.25rem; font-size: 0.85rem; color: var(--dim); }
.picks button {
font: inherit; margin-right: 0.35rem; padding: 0.3rem 0.6rem; cursor: pointer;
color: var(--ink); background: var(--card);
border: 1px solid var(--line); border-radius: 999px;
}
#cite { margin-top: 0.6rem; font-size: 0.78rem; color: var(--dim); }
</style>
</head>
<body>
<main>
<h1>State income tax on a year of wages</h1>
<p class="lede">
One HTML file, calling the free <a href="https://farbetteroff.com/api">Far Better Off API</a>.
It answers three ways, and the third is the one worth building for: an
explicit <code>null</code> for the states Far Better Off will not guess at.
Show that as $0 and you have published a wrong number.
</p>
<p class="picks">
Try:
<button type="button" data-pick="PA">Pennsylvania</button>
<button type="button" data-pick="TX">Texas</button>
<button type="button" data-pick="CA">California</button>
</p>
<form id="form">
<label>Annual wages
<input name="wages" type="number" inputmode="numeric" min="0" max="1000000000" step="1000" value="75000">
</label>
<label>State
<select name="state">
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="AR">Arkansas</option>
<option value="CA">California</option>
<option value="CO">Colorado</option>
<option value="CT">Connecticut</option>
<option value="DE">Delaware</option>
<option value="DC">District of Columbia</option>
<option value="FL">Florida</option>
<option value="GA">Georgia</option>
<option value="HI">Hawaii</option>
<option value="ID">Idaho</option>
<option value="IL">Illinois</option>
<option value="IN">Indiana</option>
<option value="IA">Iowa</option>
<option value="KS">Kansas</option>
<option value="KY">Kentucky</option>
<option value="LA">Louisiana</option>
<option value="ME">Maine</option>
<option value="MD">Maryland</option>
<option value="MA">Massachusetts</option>
<option value="MI">Michigan</option>
<option value="MN">Minnesota</option>
<option value="MS">Mississippi</option>
<option value="MO">Missouri</option>
<option value="MT">Montana</option>
<option value="NE">Nebraska</option>
<option value="NV">Nevada</option>
<option value="NH">New Hampshire</option>
<option value="NJ">New Jersey</option>
<option value="NM">New Mexico</option>
<option value="NY">New York</option>
<option value="NC">North Carolina</option>
<option value="ND">North Dakota</option>
<option value="OH">Ohio</option>
<option value="OK">Oklahoma</option>
<option value="OR">Oregon</option>
<option value="PA" selected>Pennsylvania</option>
<option value="RI">Rhode Island</option>
<option value="SC">South Carolina</option>
<option value="SD">South Dakota</option>
<option value="TN">Tennessee</option>
<option value="TX">Texas</option>
<option value="UT">Utah</option>
<option value="VT">Vermont</option>
<option value="VA">Virginia</option>
<option value="WA">Washington</option>
<option value="WV">West Virginia</option>
<option value="WI">Wisconsin</option>
<option value="WY">Wyoming</option>
</select>
</label>
</form>
<div id="answer" aria-live="polite">
<div id="amount" class="big">…</div>
<div id="detail" class="small"></div>
<div id="cite"></div>
</div>
<noscript>
<p class="small">This example is a JavaScript demo of the API. The whole table
is readable without it:
<a href="https://farbetteroff.com/state-income-tax-rates">state income tax rates</a>.</p>
</noscript>
<footer>
Numbers by <a href="https://farbetteroff.com">Far Better Off</a> — free, no key, no signup, no ads.
<a href="https://farbetteroff.com/examples">Read this page's source</a>, or
<a href="https://farbetteroff.com/examples/state-tax.html" download>save the file</a>.
</footer>
</main>
<script type="module">
// curl -O https://farbetteroff.com/calcwise.js to keep your own copy.
import { calcwise, CalcWiseError } from "https://farbetteroff.com/calcwise.js";
const cw = calcwise();
const form = document.getElementById("form");
const amountEl = document.getElementById("amount");
const detailEl = document.getElementById("detail");
const citeEl = document.getElementById("cite");
const usd = new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD", maximumFractionDigits: 0
});
const seen = new Map();
let inFlight = null;
let debounce = null;
async function update() {
const params = {
state: form.elements.state.value,
wages: Number(form.elements.wages.value)
};
const key = cw.url("state-tax", params);
if (seen.has(key)) { show(seen.get(key)); return; }
if (inFlight) inFlight.abort();
inFlight = new AbortController();
try {
const res = await cw.stateTax(params, { signal: inFlight.signal });
seen.set(key, res);
show(res);
} catch (err) {
if (err.name === "AbortError") return;
amountEl.className = "bad";
detailEl.textContent = "";
citeEl.textContent = "";
amountEl.textContent = err instanceof CalcWiseError
? err.message
: "Could not reach Far Better Off: " + err.message;
}
}
function show(res) {
const r = res.result;
// THE BRANCH THIS EXAMPLE EXISTS FOR.
//
// r.tax is null for the states Far Better Off does not model -- 30 of the 51 run
// graduated bracket tables, and the API returns no number rather than an
// average or a zero standing in for "don't know". r.reason says why.
// Treat this as a missing answer, never as $0.
if (r.tax === null) {
amountEl.className = "bad";
amountEl.textContent = "No answer for " + r.stateName;
detailEl.textContent = r.reason;
link("https://farbetteroff.com/state-income-tax-rates", "Which states Far Better Off can answer");
return;
}
// The other two branches are both real answers: 0 where the state levies
// no tax on wages, a figure where it charges one verified statutory rate.
amountEl.className = "big";
amountEl.textContent = usd.format(r.tax);
detailEl.textContent = r.stateName + " — " + r.rateLabel +
(r.excludes ? ". " + r.excludes : "");
// Every answer carries the agency it came from. Passing it on is four
// lines, and it is the difference between a number and a citation.
if (r.source) link(r.source.url, "Source: " + r.source.publisher);
else citeEl.textContent = "";
}
function link(href, text) {
citeEl.textContent = "";
const a = document.createElement("a");
a.href = href;
a.rel = "noopener";
a.textContent = text;
citeEl.appendChild(a);
}
form.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(update, 300);
});
document.querySelectorAll("[data-pick]").forEach((button) => {
button.addEventListener("click", () => {
form.elements.state.value = button.dataset.pick;
update();
});
});
update();
</script>
</body>
</html>
The answer already in the HTML
A complete Node server, no dependencies, that calls the API on the server side — so the number is in the markup you send, the page needs no JavaScript at all, and the visitor’s browser never contacts Far Better Off.
- One IP, many visitors: the rate limit is per IP, so a server that calls the API once per pageview runs out at ${RATE_LIMIT} visitors a minute. Caching is not an optimisation here, it is the design.
- The cache lifetime is read out of the API’s own Cache-Control header rather than typed in, so it follows the API instead of drifting from it.
- Three outcomes, three statuses: a bad number is your visitor’s 400, an unreachable API is your 502 — and an expired cache entry beats an error page when the answer has not moved.
- Forward only the parameters your page offers. Passing the visitor’s whole query string upstream turns your server into an open proxy for your own rate limit.
To run it: node server.mjs, then open http://localhost:8080 (Node 18+, no install step).
The whole file
#!/usr/bin/env node
// server.mjs — the Far Better Off finance API called from a server, not a browser.
// MIT licensed. This file and its siblings: https://farbetteroff.com/examples
//
// The other two examples in that folder run in the visitor's browser, which is
// the common case. This one is the other shape: your server calls the API, so
// the answer is already in the HTML you send. Nobody's browser talks to
// farbetteroff.com, nothing is blocked by an ad blocker or a strict CSP, the
// page works with JavaScript switched off, and the number is in the markup a
// crawler reads.
//
// It is also the example where the API's cache headers earn their keep. The
// public API allows 60X PER IP, and a server has one IP for
// all of its visitors — so calling the API once per pageview runs out of budget
// at 60 visitors a minute. Caching the answer for as long as the API says it is
// good (Cache-Control: max-age=3600) turns any number of visitors asking the
// same question into one upstream call an hour. The footer of the page prints
// the running total so you can watch that happen.
//
// Run it:
//
// node server.mjs then open http://localhost:8080
//
// Requirements: Node 18 or newer (for global fetch). No dependencies, no build
// step, no API key, no account.
import { createServer } from "node:http";
// Both are env-overridable so the same file runs in your tests: point
// CALCWISE_API at a stub and you can exercise the error and outage branches
// below without waiting on the network.
const API = process.env.CALCWISE_API || "https://farbetteroff.com/api/v1";
const PORT = Number(process.env.PORT || 8080);
// Say who you are. It costs nothing and it means a maintainer looking at
// traffic can tell a real integration from a scraper.
const UA = "calcwise-server-example (+https://farbetteroff.com/examples)";
// ---------------------------------------------------------------------------
// The cache
// ---------------------------------------------------------------------------
// Keyed on the exact upstream URL, with the lifetime taken from the API's own
// Cache-Control header rather than a number typed in here — if the API ever
// says an answer is good for longer or shorter, this follows without an edit.
// A Map in memory is right for one process; swap it for Redis or your framework
// of choice and nothing else on this page changes.
const cache = new Map();
let upstreamCalls = 0;
function ttlMsFrom(response) {
const header = response.headers.get("cache-control") || "";
const match = header.match(/max-age=([0-9]+)/);
// Floor of 60s so a missing or zero header still can't turn one visitor into
// one upstream call.
return Math.max(match ? Number(match[1]) : 0, 60) * 1000;
}
/**
* Ask the API one question and return a plain result object.
*
* Never throws: a caller rendering a page wants something to render, not an
* exception. Three outcomes worth handling separately —
* ok: true the answer, fresh from cache or from the API
* ok: false, issues the API rejected the inputs, and said why
* ok: false, stale answer the API was unreachable, so the last good
* answer is served with a note
*/
async function ask(endpoint, params) {
const url = API + "/" + endpoint + "?" + new URLSearchParams(params).toString();
const hit = cache.get(url);
const now = Date.now();
if (hit && now - hit.at < hit.ttlMs) {
return { ok: true, data: hit.data, from: "cache", age: Math.round((now - hit.at) / 1000) };
}
try {
upstreamCalls += 1;
const response = await fetch(url, {
headers: { "User-Agent": UA, Accept: "application/json" },
// Your page's response time is your problem, not the API's. Give up and
// render something rather than hang.
signal: AbortSignal.timeout(5000),
});
const body = await response.json();
if (!response.ok) {
// 400 carries error.issues, one entry per bad parameter, each with the
// reason. 429 means the minute's budget is spent; Retry-After says when.
return {
ok: false,
status: response.status,
message: body && body.error ? body.error.message : "HTTP " + response.status,
issues: body && body.error && body.error.issues ? body.error.issues : [],
retryAfter: response.headers.get("retry-after"),
};
}
cache.set(url, { at: now, ttlMs: ttlMsFrom(response), data: body });
return { ok: true, data: body, from: "api", age: 0 };
} catch (error) {
// Network error, timeout, bad JSON. If we ever had an answer to this exact
// question, an expired one beats an error page: the rate on a mortgage does
// not move in the minute your upstream is down.
if (hit) {
return {
ok: true,
data: hit.data,
from: "stale",
age: Math.round((now - hit.at) / 1000),
warning: "farbetteroff.com did not answer (" + error.message + "); showing the last good result.",
};
}
return { ok: false, status: 0, message: "farbetteroff.com did not answer: " + error.message, issues: [] };
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// A line break. This file is published verbatim at
// https://farbetteroff.com/examples/server.mjs and is kept free of backslash
// escapes so that stays literally true — there is one copy of it, not two.
const NL = String.fromCharCode(10);
const escape = (value) =>
String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
const usd = (n) => "$" + Number(n).toLocaleString("en-US", { maximumFractionDigits: 0 });
const usdCents = (n) => "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// The fields this page exposes. Names and bounds are the API's own — see
// https://farbetteroff.com/api#mortgage for the full list.
const FIELDS = [
{ name: "homePrice", label: "Home price", value: 420000, step: 1000 },
{ name: "downPayment", label: "Down payment ($)", value: 84000, step: 1000 },
{ name: "rate", label: "Rate (% APR)", value: 6.5, step: 0.125 },
{ name: "termYears", label: "Term (years)", value: 30, step: 1 },
{ name: "propertyTaxAnnual", label: "Property tax / yr", value: 4800, step: 100 },
{ name: "insuranceAnnual", label: "Insurance / yr", value: 1800, step: 100 },
];
function field(spec, given) {
const value = given[spec.name] === undefined ? spec.value : given[spec.name];
return (
'<label>' + escape(spec.label) +
'<input name="' + spec.name + '" type="number" step="' + spec.step + '" value="' + escape(value) + '"></label>'
);
}
function answerHtml(answer) {
if (!answer.ok) {
// The API explains itself per parameter. Show its words, not "something
// went wrong" — it is the difference between a fixable page and a dead one.
const issues = answer.issues.length
? '<ul>' + answer.issues.map((i) => '<li><code>' + escape(i.param) + '</code> — ' + escape(i.message) + '</li>').join("") + '</ul>'
: "";
return '<div class="card bad"><strong>No answer.</strong><p>' + escape(answer.message) + '</p>' + issues + '</div>';
}
const r = answer.data.result;
const b = r.breakdown;
const rows = [
["Principal & interest", b.principalAndInterest],
["Property tax", b.propertyTax],
["Insurance", b.insurance],
["PMI", b.pmi],
].filter((row) => row[1] > 0);
return (
'<div class="card">' +
'<p class="dim">Estimated monthly payment</p>' +
'<p class="big">' + usdCents(r.monthlyTotal) + '</p>' +
'<table>' +
rows.map((row) => '<tr><th>' + row[0] + '</th><td>' + usdCents(row[1]) + '</td></tr>').join("") +
'</table>' +
'<p class="dim">On a ' + usd(r.loanAmount) + ' loan (' + r.downPaymentPercent.toFixed(0) + '% down) over ' +
escape(r.payoffTermLabel) + '. ' + (r.pmi ? "PMI applies below 20% down." : "No PMI at this down payment.") + '</p>' +
(answer.warning ? '<p class="warn">' + escape(answer.warning) + '</p>' : "") +
'</div>'
);
}
const STYLE = [
' :root { color-scheme: light dark; --bg:#fff; --card:#f8fafc; --ink:#0f172a; --dim:#64748b; --line:#e2e8f0; --accent:#047857; --bad:#b91c1c; }',
' @media (prefers-color-scheme: dark) { :root { --bg:#0f172a; --card:#1e293b; --ink:#e2e8f0; --dim:#94a3b8; --line:#334155; --accent:#34d399; --bad:#fca5a5; } }',
' * { box-sizing: border-box; }',
' body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 system-ui, sans-serif; }',
' main { max-width:44rem; margin:0 auto; padding:2rem 1rem 4rem; }',
' h1 { font-size:1.6rem; margin:0 0 .25rem; }',
' .dim { color:var(--dim); }',
' form { display:grid; grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr)); gap:.75rem; margin:1.5rem 0; }',
' label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); }',
' input, button { font:inherit; padding:.5rem .6rem; border:1px solid var(--line); border-radius:.5rem; background:var(--bg); color:var(--ink); }',
' button { align-self:end; background:var(--accent); color:#fff; border:0; font-weight:600; cursor:pointer; }',
' .card { background:var(--card); border:1px solid var(--line); border-radius:.9rem; padding:1.25rem; }',
' .card.bad { border-color:var(--bad); }',
' .big { font-size:2.4rem; font-weight:800; margin:.1rem 0 .8rem; }',
' table { width:100%; border-collapse:collapse; font-size:.95rem; }',
' th { text-align:left; font-weight:500; color:var(--dim); padding:.3rem 0; }',
' td { text-align:right; font-variant-numeric:tabular-nums; padding:.3rem 0; }',
' .warn { color:var(--bad); font-size:.85rem; margin:.8rem 0 0; }',
' .notes, footer { color:var(--dim); font-size:.82rem; }',
' footer { margin-top:2rem; border-top:1px solid var(--line); padding-top:1rem; }',
' a { color:var(--accent); }',
].join(NL);
const SOURCE_WORDS = {
api: "a fresh call to the API",
cache: "this process's cache",
stale: "an expired cache entry, because the API was unreachable",
};
function page(answer, given) {
const notes = answer.ok ? answer.data.notes : [];
return [
'<!doctype html>',
'<html lang="en">',
'<head>',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<title>What would this cost a month?</title>',
'<style>',
STYLE,
'</style>',
'</head>',
'<body>',
'<main>',
'<h1>What would this cost a month?</h1>',
'<p class="dim">Worked out on the server by <a href="https://farbetteroff.com/api">the free Far Better Off API</a>. ' +
'No JavaScript runs on this page, and your browser never contacts farbetteroff.com.</p>',
'<form method="get">' + FIELDS.map((f) => field(f, given)).join("") + '<button type="submit">Update</button></form>',
answerHtml(answer),
notes.length ? '<ul class="notes">' + notes.map((n) => '<li>' + escape(n) + '</li>').join("") + '</ul>' : "",
'<footer>',
'<p>This answer came from <strong>' + escape(SOURCE_WORDS[answer.from] || "the API") + '</strong>' +
(answer.age ? ', ' + answer.age + 's old' : "") + '. ' +
'Upstream calls since this process started: <strong>' + upstreamCalls + '</strong>. ' +
'Reload with the same numbers and that total stays put: the cache answers the second visitor, not the API.</p>',
'<p>Payment estimates by <a href="https://farbetteroff.com">Far Better Off</a>. Educational estimate, not financial advice.</p>',
'</footer>',
'</main>',
'</body>',
'</html>',
"",
].join(NL);
}
// ---------------------------------------------------------------------------
// The server
// ---------------------------------------------------------------------------
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/") {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found. The page is at /" + NL);
return;
}
// Only pass through parameters this page offers, with its defaults filled in.
// Forwarding the visitor's whole query string would let anyone use your
// server's rate-limit budget as an open proxy, and would fill the cache with
// keys nobody asks for twice.
const params = {};
for (const spec of FIELDS) {
const given = url.searchParams.get(spec.name);
params[spec.name] = given === null || given.trim() === "" ? spec.value : given.trim();
}
const answer = await ask("mortgage", params);
const html = page(answer, params);
// Say what actually happened. A bad number in the query string is the
// visitor's 400; an upstream that is down or rate-limiting is your 502/503,
// and a cache that holds either would serve the mistake to everyone after.
const headers = { "Content-Type": "text/html; charset=utf-8" };
let status = 200;
if (!answer.ok) {
status = answer.status === 400 ? 400 : answer.status === 429 ? 503 : 502;
if (answer.retryAfter) headers["Retry-After"] = answer.retryAfter;
headers["Cache-Control"] = "no-store";
} else {
// Your own caching policy, separate from the API's. A shared cache can hold
// this for an hour; the visitor's browser revalidates sooner.
headers["Cache-Control"] = "public, max-age=60, s-maxage=3600";
}
res.writeHead(status, headers);
res.end(html);
});
server.listen(PORT, () => {
console.log("Far Better Off server-side example on http://localhost:" + PORT);
console.log("Answers come from " + API + " and are cached for as long as it says they are good.");
});
The same thing as a Next.js route handler
The server example translated into an App Router route handler — the shape most readers are actually working in, and the one place the Node version does not translate line for line.
app/api/payment/route.ts · 29 lines of code in 64 · calls /api/v1/mortgage- A module-level Map is one cache per process. On serverless that is one per instance, cold on each instance's first request; the framework's own data cache is shared and survives a cold start.
- That cache takes its lifetime from you, not from the API's Cache-Control header — so the number is written down here, and kept equal to what the API says.
- Forward a named list of parameters, never the visitor's whole query string: your route calls with one IP for all of your visitors, so an open proxy spends your whole budget.
- Three outcomes, three statuses: bad inputs are your visitor's 400 with the API's own per-parameter explanation, an unreachable API is your 502, and a spent budget is a 503 rather than a leaked 429.
Not a file you can run: this one needs the app around it, so it is printed here rather than served at a URL — but it is type-checked against the real next/server types on every test run, and the revalidate seconds are asserted equal to the max-age the API actually sends.
// app/api/payment/route.ts
//
// The Far Better Off finance API behind your own endpoint, in Next.js. Your server
// asks, so the answer is in the HTML you send: nobody's browser talks to
// farbetteroff.com, nothing is blocked by an ad blocker or a strict CSP, and the
// page works with JavaScript switched off.
//
// The complete, framework-free version of this is https://farbetteroff.com/examples/server.mjs.
// Everything below is the same program; only the cache is different.
import type { NextRequest } from "next/server";
const API = "https://farbetteroff.com/api/v1";
// Forward a named list, never the visitor's whole query string. Your route
// calls the API with one IP for all of your visitors, and the public limit is
// 60 requests a minute per IP — an open proxy spends that on a stranger.
const FORWARD = ["homePrice", "downPayment", "rate", "termYears"] as const;
export async function GET(request: NextRequest) {
const asked = request.nextUrl.searchParams;
const params = new URLSearchParams();
for (const name of FORWARD) {
const value = asked.get(name);
if (value !== null) params.set(name, value);
}
let upstream: Response;
try {
upstream = await fetch(API + "/mortgage?" + params.toString(), {
headers: { Accept: "application/json" },
// server.mjs keeps a Map and reads the lifetime out of the response's
// Cache-Control. Here the cache is the framework's Data Cache instead:
// shared by every instance and still warm after a cold start, which a
// module-level Map is not. It takes its lifetime from this number rather
// than from the header, so this is the header's own max-age, written
// down. Tag it and one revalidateTag("calcwise") drops every answer.
next: { revalidate: 3600, tags: ["calcwise"] },
signal: AbortSignal.timeout(5000),
});
} catch {
// Unreachable, or slower than your page can wait: your outage, not your
// visitor's mistake. server.mjs serves its last good answer here instead,
// which is the better behaviour and needs a store you can read on a miss.
return Response.json({ error: "calculator unavailable" }, { status: 502 });
}
const body = await upstream.json();
if (!upstream.ok) {
// The API rejects bad inputs with a per-parameter explanation, and the
// inputs are the ones you chose to forward, so pass it through rather than
// inventing a message. A 429 is your budget, not your visitor's fault.
const status = upstream.status === 429 ? 503 : 400;
return Response.json(body, { status });
}
// Your own callers get a cache lifetime too: a minute in the browser, an hour
// at your CDN. The upstream answer is a pure function of the query string, so
// the only thing that goes stale is your parameter list.
return Response.json(body.result, {
headers: { "Cache-Control": "public, max-age=60, s-maxage=3600" },
});
}
In something other than Next: the shape is the same and only the cache moves. On a long-lived process — Express, Fastify, a Node or Bun server, an Astro site on a node adapter — server.mjs translates directly, Map and all, because there is one process and it stays warm. It is serverless that breaks it: a module-level Mapthere is one cache per instance, empty on each instance's first request, so reach for whatever your platform shares between them (Next's data cache above, Nitro's cachedFunction, Cloudflare's Cache API, or plain Redis) and hand it a lifetime of 3,600 seconds. If your Next app has cacheComponents turned on, the equivalent is a "use cache" helper with cacheLife — the directive cannot go in the route handler body itself.
Take them offline
The 2 browser examples import the client over the network, which is the shortest thing to paste into a CodePen. (The Node server does not: it calls the API with plain fetch, so it has nothing to vendor.) On a page you actually ship, keep your own copy of the client instead: it is 195.2 KB of dependency-free JavaScript covering all 28 endpoints — 170.4 KB of that is the JSDoc types at the bottom, which your editor reads and a browser skips — with 193.8 KB of TypeScript types beside it, and then the only thing your page needs from us at runtime is the answer.
# Keep your own copy — then nothing on your page depends on us being up.
curl -O https://farbetteroff.com/calcwise.js
curl -O https://farbetteroff.com/calcwise.d.ts # optional: TypeScript types
# In the example, change one line:
# import { calcwise } from "https://farbetteroff.com/calcwise.js";
# import { calcwise } from "./calcwise.js";Or take the examples themselves:
curl -O https://farbetteroff.com/examples/mortgage.html
curl -O https://farbetteroff.com/examples/state-tax.html
curl -O https://farbetteroff.com/examples/server.mjsIf you want the calculator, not the API
These examples are for building your own interface on top of Far Better Off's math. If you just want a working calculator on your page, you do not need to write any of this:
- The embed builder — pick a calculator, a theme and the starting numbers, and copy a snippet that sizes itself.
- The WordPress plugin — a block and a shortcode, no code at all.
- The API reference — all 28 endpoints, each with a console you can type into. Base URL
https://farbetteroff.com/api/v1.