I needed a rent receipt generator for a small landlord. The obvious answer was a SaaS: sign up, store tenants in a database, email PDFs. I built the opposite: one HTML file, no server, no build step, that prints a one-page PDF from the browser. Here is what that constraint taught me, and where it breaks.
The constraint
- One file. Open it from disk or from a static host, it works the same.
- No external scripts, no fonts from a CDN, no analytics. The file must work offline in 2036.
- State survives a reload, but never leaves the machine.
- The printed result must be a clean Letter or A4 page, not a screenshot of a web form.
Three things that did most of the work
1. Two layouts in one document, switched by @media print.
The page has a form on the left and the document on the right. The print stylesheet hides everything with a .noprint class and lets the document fill the page:
@page { size: auto; margin: 18mm; } @media print { .noprint { display: none !important; } body { background: #fff; } .sheet { box-shadow: none; width: auto; margin: 0; } } "Save as PDF" in Chrome, Edge, Firefox and Safari all go through the same path, so the user never installs anything. The trap: a header that is 2 px too tall pushes a signature line onto page two. I now test every tool headless with --print-to-pdf and count the pages in the output (a regex on /Type /Page is enough).
2. localStorage as the whole backend.
Every input has an id. On input, the tool serialises the fields to one JSON blob under a versioned key; on load, it restores them. That is the entire persistence layer:
const FIELDS = ["landlord","tenant","address","month","year","rent"]; function save(){ const o={}; for(const f of FIELDS) o[f]=$(f).value; localStorage.setItem("rentreceipt.v1", JSON.stringify(o)); } function load(){ const o=JSON.parse(localStorage.getItem("rentreceipt.v1")||"{}"); for(const f of FIELDS) if(o[f]!==undefined) $(f).value=o[f]; } Next month the landlord clicks "Next month": the period and receipt number increment, everything else stays. A bug I shipped: I set the default month from new Date() after calling load(), so a restored empty value won the race and every receipt defaulted to January. Set defaults first, then restore.
3. Amount in words, without a library.
A receipt should say "$1,200.00 (one thousand two hundred dollars)". A number-to-words function is about forty lines for English, and the cents are the tricky part (round first, split, then format). I kept it per-currency rather than generic because "euros and cents" and "pounds and pence" need their own words anyway.
Where the single-file approach breaks
- No email delivery. The user attaches the PDF themselves.
- No multi-device sync. The data lives in one browser profile; clearing site data erases it. I say so on the page.
- No updates in place. If I fix a bug, people who downloaded v1.0 keep v1.0. The free version is hosted so it always gets the fix; the paid files are versioned zips.
- Mobile Safari prints oddly: print preview works, but
@pagemargins are partially ignored.
Result
The free version is hosted here: https://paperworkkit.netlify.app/en/ (and as a browser-playable tool on itch.io: https://paperworkkit.itch.io/rent-receipt-free). The same engine runs five more documents a small landlord needs (late notice, deposit return, rent increase, inspection checklist, annual statement), sold as a kit of offline files: https://paperworkkit.itch.io/landlord-paperwork-kit
If you have built single-file tools and solved the mobile print problem, I would like to hear how.
