I built Calcapay, a set of free calculators for US paychecks, mortgages, loans and taxes. It is a static site: Eleventy, plain CSS, a little vanilla JavaScript, and no backend. All the math runs in the browser.
The hard part of a calculator site is not the calculator. It is keeping every number on every page correct: the result in the widget, the worked example in the article under it, the table on the state page, the figure in the blog post. When a tax figure changes, all of them have to change together.
This post is about the structure that makes that work, and the things that bit me on the way.
The core idea: one module, two runtimes
Each calculator is a pure function in its own file. No DOM access, no globals except the one it defines. The same file is loaded in the browser and required by Node at build time.
The pattern is a plain IIFE plus a CommonJS guard at the end:
const PaycheckCalc = (function (tax) {
// ...brackets, FICA, state tax...
function calculate(params) { /* returns netAnnual, fedTaxAnnual, ... */ }
return { calculate, computeFederalTax, computeStateTax };
})(typeof module !== 'undefined' && module.exports
? { federal: require('../../../_data/tax/federal2026.json'), states: require('../../../_data/tax/states2026.json').states }
: window.WCH_TAX);
if (typeof module !== 'undefined' && module.exports) module.exports = PaycheckCalc;
In Node, the tax data comes straight from the JSON files in src/_data/. In the browser, the build writes the same JSON into window.WCH_TAX. One set of numbers, one implementation.
Articles that cannot drift from the calculator
Because Node can run the calculators, Eleventy can too. A global data file computes every worked example at build time:
// src/_data/examples.js
const Mortgage = require('../assets/js/calc/mortgage.js');
module.exports = function () {
const m30 = Mortgage.calculate({ homePrice: 400000, downPayment: 80000, interestRate: 6.5, loanTermYears: 30 });
return {
mortgage: {
m30,
extra: [100, 250, 500].map(amount => ({ amount, ...Mortgage.payoffWithExtra(m30.loanAmount, 6.5, 30, amount) }))
}
};
};
The article template never contains a typed-in dollar amount:
On the {{ ex.m30.loanAmount | usd0 }} loan, an extra $100 a month
saves {{ ex.extra[0].interestSaved | usd0 }} in interest.
Blog posts are Markdown with Nunjucks enabled, so they use the same data. If I fix a bug in the mortgage module, every article that quotes a mortgage figure is corrected on the next build.
Tests against hand-computed values
The calculators are covered with node:test, no framework. The rule I follow: expected values are worked out by hand from the published tables, never by running the code and pasting its output.
// Expected values are hand-computed from IRS Rev. Proc. 2025-32 tables (tax year 2026).
test('federal tax, single, $75,000', () => {
const r = PaycheckCalc.calculate({ ...base, gross: 75000 });
near(r.fedTaxAnnual, 7670); // 1,240 + 4,560 + 1,870 on 58,900 taxable
near(r.ficaTaxAnnual, 5737.5);
});
Copying the program's output into a test only proves the program agrees with itself. The comment with the arithmetic is the point: anyone can check it against the IRS table.
These tests caught real bugs. The original code applied a flat rate per state, used single brackets for head of household, and had the previous year's numbers labelled as the current year. A later review found states whose deductions phase out with income, which the first version ignored. Each fix started as a failing test with a hand-computed value.
Generating pages from data
Two sets of pages are generated with Eleventy pagination:
- 51 state paycheck pages (
/paycheck-calculator/california/), one per state plus DC - 51 hourly wage pages (
/hourly-to-salary-calculator/25-an-hour/)
Each one is a single template and a data file. The numbers on every page come from the same calculator modules, so each page carries its own figures rather than the same paragraph with the state name swapped.
Two Eleventy details cost me time here.
Paginated pages are missing from collections by default. My sitemap is built from collections.all, and only the first state appeared. The fix is one line in the pagination block:
pagination:
data: tax.states2026.states
size: 1
alias: state
addAllPagesToCollections: true
Computed data that depends on other computed data can break. I had jsonld calling data.breadcrumbs.map(...), where breadcrumbs was also computed. During dependency resolution Eleventy passes placeholder values, and the build failed with data.breadcrumbs.map is not a function. Moving the logic into plain helper functions inside a template data file (paycheck-states.11tydata.js) and calling them from each computed property fixed it.
One script per page
Every page loads exactly one deferred script: shared code, the data that page needs, its calculator module and its page controller. An Eleventy JavaScript template concatenates the parts and minifies them with esbuild:
async render(data) {
const { bundle } = data;
const parts = [toolsFor(data), read('core.js')];
if (bundle.calc) {
parts.push(dataFor(bundle, data), read(`calc/${bundle.calc}.js`), read(`pages/${bundle.calc}.js`));
}
const result = await esbuild.transform(parts.join('\n'), { minify: true, target: 'es2019' });
return result.code;
}
esbuild.transform without bundling keeps top-level names intact, which matters because the files share a few globals. State pages get a bundle with only their own state's tax profile; if a visitor picks another state from the dropdown, the full list is fetched then.
An SEO audit that runs on the build output
After every build, a small Node script reads _site/ and fails on problems I never want to ship:
if (canonical !== SITE + page) fail(`canonical is ${canonical}, expected ${SITE + page}`);
if (titles.has(title)) fail(`duplicate title with ${titles.get(title)}`);
if (/176,100|\$15,000 standard/.test(html)) fail('stale 2025 tax figure found');
It also checks: exactly one <h1>, valid JSON-LD, every internal link resolving to a built file, every indexable page present in the sitemap, labels on every form control, and exactly one bundled script per page. The stale-figure check exists because I once shipped last year's Social Security wage base; it now fails the build if that number ever appears again.
Hosting on Cloudflare Workers static assets
The site deploys as Workers static assets. The config is short:
{
"name": "calcapay",
"compatibility_date": "2024-09-23",
"assets": {
"directory": "./_site",
"not_found_handling": "404-page"
}
}
Without not_found_handling, unknown URLs returned a 404 status with an empty body instead of my 404 page.
Security headers live in _headers. The calculators can be embedded on other sites from /embed/..., so those pages need to drop the X-Frame-Options header the rest of the site sends. The ! prefix removes a header for matching paths:
/embed/*
! X-Frame-Options
X-Robots-Tag: noindex
Real sitemap dates from git
lastmod in the sitemap is the date of the last commit that touched a page's template or the data it renders. On the hosted build every page got the same date. The build was cloning shallowly, so git log only knew the latest commit. The build script now tries to fetch full history first and carries on if it cannot:
"build": "(git fetch --unshallow --quiet 2>/dev/null || true) && eleventy"
What I would do the same way again
- Keep the math pure and shared. It is what lets content, tests and the UI agree.
- Write expected test values by hand. It is slower, and it is the only kind of test that catches a wrong formula.
- Audit the built HTML, not the templates. The output is what search engines and users see.
- Generate pages only where the data really differs. A template is fine; identical content with a different name is not.
If you want to see the result, the state tax comparison page is the one that pulls the most of this together: a sortable table, a chart and a CSV download, all generated from the same data files as the calculators.
Top comments (0)