A freelancer friend asked me for "a thing where I type in line items and get a clean invoice PDF." My first instinct was the usual stack: a server route, a headless-Chrome PDF renderer, maybe a queue so the Lambda doesn't time out. Then I stopped — none of that data should leave the browser in the first place. Client names, rates, amounts: it's exactly the stuff you don't want sitting in someone's request logs. So I built the whole thing client-side. Here's what I learned.
Two ways to make a PDF in the browser
There are really only two approaches, and picking the wrong one costs you a day:
-
Rasterize the DOM — render an HTML invoice, screenshot it with
html2canvas, and drop the image into a single-page PDF. Fast to wire up, looks exactly like your HTML… and produces a picture of an invoice. The text isn't selectable, it's blurry when printed, and a one-page image can't paginate when someone adds 40 line items. -
Draw the PDF directly — use
jsPDF's text/vector API to lay the document out yourself. More code, but you get real selectable text, crisp print output, and genuine multi-page support.
For an invoice — a document people print, forward, and sometimes paste a number out of — vector text wins. So this is the jsPDF route.
The 30-second version
jsPDF gives you a document you draw onto in points, then save:
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<script>
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: "pt", format: "letter" });
doc.setFont("helvetica", "bold");
doc.setFontSize(26);
doc.text("INVOICE", 540, 60, { align: "right" });
doc.save("invoice.pdf");
</script>
Two things that bit me immediately: it loads onto window.jspdf (lowercase namespace, capital-S jsPDF constructor), and unit: "pt" matters. Default is millimeters; a US Letter page is 612 × 792 points, and once you're thinking in points the rest of the layout math stays in one coordinate system.
Text is positioned by baseline, and you move the cursor yourself
This is the mental shift coming from HTML. There's no flow layout — every doc.text(string, x, y) places that string's baseline at (x, y). You keep your own y and increment it after each line:
let y = 90;
doc.setFontSize(11).setFont("helvetica", "bold");
doc.text(fromName || "Your business", 48, y);
y += 14; // advance the cursor by the line height
doc.setFontSize(9.5).setFont("helvetica", "normal");
doc.text(addressLine, 48, y);
Right-aligning the amounts column is just doc.text(value, x, y, { align: "right" }) with x pinned to the right margin. Once you accept that you're a cursor pushing text around a canvas, the layout gets predictable fast.
Wrapping long text without overflow
A "Notes" field or a long line-item description will happily run off the page edge, because doc.text does not wrap. splitTextToSize is the fix — it breaks a string into an array of lines that fit a given width:
function wrapped(doc, text, x, y, maxWidth, lineHeight) {
const lines = doc.splitTextToSize(String(text || ""), maxWidth);
lines.forEach((ln) => { doc.text(ln, x, y); y += lineHeight; });
return y; // return the new cursor so the next block starts below
}
Returning the updated y is the small trick that keeps everything below it from colliding.
Paginate before you draw the row, not after
The invoice table is where naive code breaks: someone adds enough line items to run past the bottom margin and the last rows just vanish off the page. The fix is to check the cursor before drawing each row and add a page if you're close to the edge:
items.forEach((it) => {
if (y > doc.internal.pageSize.getHeight() - 120) {
doc.addPage();
y = 48; // reset cursor to top margin on the new page
}
doc.text(it.desc, 54, y);
doc.text(money(it.qty * it.rate), 558, y, { align: "right" });
y += 22;
});
The - 120 is headroom so the totals block underneath the table never gets orphaned alone at the very bottom.
Embedding a logo — entirely locally
People want their logo on the invoice, and this is the part that most tempts you to add an upload endpoint. You don't need one. A <input type="file"> plus FileReader.readAsDataURL gives you a base64 data URL that both the on-screen preview and jsPDF can consume directly:
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result; // "data:image/png;base64,..."
// on-screen preview:
previewImg.src = dataUrl;
// and straight into the PDF, no network round-trip:
doc.addImage(dataUrl, "PNG", 48, 40, 96, 48, undefined, "FAST");
};
reader.readAsDataURL(file);
The "FAST" compression flag keeps the output small, and the image never touches a server — it's read off the user's disk into memory and stamped into the PDF. That's the whole privacy story in one API.
Money formatting: do it once, in one helper
Mixing currency symbols and fixed decimals inline is how you end up with $1234.5 on a customer-facing document. Centralize it:
const fmt = (n) =>
symbol + (isFinite(n) ? n : 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
toLocaleString handles the thousands separators and the trailing-zero cents for free, and you pass the same string to both the live preview and doc.text, so the two can never disagree.
Why client-side at all
After wiring this up I couldn't find a reason to put any of it on a server. The invoice data — who you're billing, for how much — never leaves the browser. There's nothing to rate-limit, nothing to pay for, no PII sitting in a log, and it works on a plane. The only thing a backend would buy you is generating invoices for non-browser clients, and if a human is filling in the form, you don't have one.
If you just want to type in line items and grab the PDF without wiring up jsPDF yourself, I put a no-signup version online while building this — Invoicely runs entirely in the browser (same jsPDF-under-the-hood approach, nothing uploaded). Handy for a one-off invoice, or for eyeballing how a layout paginates before you write the code.
Gotchas worth knowing
-
unit: "pt"from the start. Switching coordinate systems halfway through a layout is miserable. Letter =612 × 792pt, A4 =595 × 842pt. -
Baseline, not top-left.
doc.textpositions the text baseline; if your first line looks clipped at the top, push your startingydown by roughly the font size. - Check pagination before the row. Always test with a 30-item invoice — the bug only shows up past the first page break.
-
Data URLs, not Blob URLs, for
addImage. jsPDF wants the base64 string;URL.createObjectURLgives you ablob:reference it can't embed.
That's the whole thing — a print-ready, multi-page invoice generator with zero backend. jsPDF is more manual than rasterizing the DOM, but for a document people actually print and read, the selectable vector text is worth the extra cursor-pushing.
Top comments (0)