I run a free financial calculator site: 38 calculators, Next.js App Router, fully static, all math in the browser. When I audited it last month I found the problem you probably have in your own app too: reference numbers copy-pasted into too many places.
The 2026 tax brackets lived in four calculators, three articles and a dozen FAQ answers. The default mortgage rate was typed into eight calculators, and every worked example computed from it went stale the week the rate moved. Worse, the FAQ answers feed JSON-LD, so a stale number was also stale in search results.
One module per kind of reference data
// src/lib/market-data.ts
import type { SourceLink } from "@/components/Sources";
export const MORTGAGE_MARKET = {
thirtyYear: 7.4, // Freddie Mac PMMS, %
thirtyYearYearAgo: 6.27,
asOf: "2026-10-15",
asOfLabel: "October 15, 2026",
checkedOn: "2026-10-20",
checkedLabel: "October 2026",
} as const;
export const MORTGAGE_MARKET_SOURCES: SourceLink[] = [
{ label: "Freddie Mac PMMS (Oct. 15, 2026)", href: "https://www.freddiemac.com/pmms" },
];
export const DEFAULT_MORTGAGE_RATE = String(MORTGAGE_MARKET.thirtyYear);
Calculators import the default. Articles build their sentences from the constants instead of pasting "7.40%". Tax data has the same shape in tax-data.ts: values, taxYear, checkedOn, sources. A refresh is now a one-file edit plus recomputing the examples.
The client/server boundary bug
My first version exported these constants from the calculator component. The calculator is a "use client" file; the article that quotes the rate is a server component. In the App Router, anything you import from a "use client" module into a server component is a client reference, not the value. The fix is boring: shared constants live in plain lib modules that both sides import.
Make the citation a component, not a convention
<Sources
items={MORTGAGE_MARKET_SOURCES}
checked={MORTGAGE_MARKET.checkedLabel}
note="Your lender's quote will differ."
/>
Programmatic variant pages carry the same data (sources, sourcesChecked, sourcesNote on the variant type), so a page cannot quote a rate without rendering where it came from and when it was checked.
Verify the built HTML, not the source
There is no browser test suite, so after next build I check .next/server/app/**/*.html:
- Strip tags and assert the pre-filled calculator's output appears in the text (for example
$2,401.16on the house payment page). This catches FAQ text that drifted from the calculator. - Parse every JSON-LD block (652 of them):
for (const [, json] of html.matchAll(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)) {
JSON.parse(json);
}
- Grep for joined text:
rg "</(strong|a|em|b)>[A-Za-z0-9]" .next/server/app. In my setup, a multi-line JSX text node containing'swallowed the space after a preceding inline element and rendered "Label:text". Writing literal characters instead of entities fixed it. ## Measure statistics, do not recall them An article table claimed healthcare inflation of 5.2% a year and college tuition of 6.0%, "based on CPI component data". I pulled the BLS series through FRED and computed the compound annual change from September 2006 to September 2026:
const cagr = (a, b, years) => (Math.pow(b / a, 1 / years) - 1) * 100;
cagr(338.3, 593.651, 20); // medical care (CPIMEDNS) -> 2.85
cagr(481.7, 938.353, 20); // college tuition and fees (CUUR0000SEEB) -> 3.39
The real numbers are 2.9% and 3.4%. The page now names the series and the window, and a planning assumption like "model 5% for college" is labeled as a stress test next to the measured rate.
Embeds that resize themselves
Calculators can be embedded with an iframe. Inside the frame, a small client component reports its height:
useEffect(() => {
const el = document.getElementById(targetId);
if (!el || window.parent === window) return;
let last = 0;
const report = () => {
const height = Math.ceil(el.getBoundingClientRect().height);
if (height === last) return;
last = height;
window.parent.postMessage({ type: "calcwise:resize", slug, height }, "*");
};
const observer = new ResizeObserver(report);
observer.observe(el);
report();
return () => observer.disconnect();
}, [slug, targetId]);
The optional host script checks event.origin, matches frame.contentWindow === event.source, and clamps the height to 200-4000px. The message contains a height and nothing else, which is why "*" as the target origin is acceptable here. To pick sensible default heights I load each embed in a hidden 720px iframe and read the messages. That caught four calculators whose layout had grown since the last measurement.
Refresh on events, not on a schedule
A daily refresh job mostly produced date bumps. Now it is event-driven: the IRS inflation-adjustment Rev. Proc. (usually October or November), the SSA COLA, a 0.25-point move in the Freddie Mac average, Census ACS releases, and a quarterly sweep.
If you want to see the result, the methodology page lists how each figure is sourced, the calculators are free to embed, and the state-level dataset is at /reports/mortgage-affordability-by-state.
Top comments (1)