All writing
EngineeringJuly 10, 20263 min read

Invoice PDFs Belong on the Server: Four Attempts in One Day

html2canvas choked on Tailwind's lab() colours, jsPDF drawing drifted from the design, an iframe was a workaround. The fix was to stop making PDFs in the browser.

Henry Iddirisu

Henry Iddirisu

AI product engineer

An invoice document moving from a browser window to a server rack

"Download invoice" sounds like a small ticket.

The DUBTEL AI portal needed a billing page: a list of invoices, a detail view, and a Download PDF button. The invoice had to match the design, with the tenant's real logo, an address block, and line items.

It took four attempts in one day. Each attempt taught me something about where PDFs should be made.

Attempt 1: screenshot the page with html2canvas

The quickest route: render the invoice as HTML, capture it with html2canvas, and put the image into a PDF.

It failed with a colour parse error. The portal uses Tailwind CSS v4, whose default palette is defined in modern colour spaces, and html2canvas can't parse them. It hit a lab() value and gave up.

Attempt 2: draw it with jsPDF

To avoid CSS parsing entirely, I drew the invoice with jsPDF's drawing API: text, lines, rectangles, and the logo placed by coordinates. That worked, but now the design lived in two places, the React component on screen and a list of coordinates in the PDF code. Every design change would have to be made twice.

Attempt 3: isolate the styles

Next I tried rendering the invoice in a hidden element with inline styles, then in an isolated iframe, so html2canvas never saw Tailwind's colours. It produced a pixel-perfect PDF. It was also clearly a workaround for a browser library's limits.

Attempt 4: ask the server

The final version doesn't generate anything in the browser. The Download button calls a server endpoint that returns the PDF, and the browser saves the file. No print dialog, no colour parsing, one source of truth for the layout.

On the Python side, both backends I work on render invoices with WeasyPrint, which turns HTML and CSS into a PDF on the server. It has its own quirks:

  • Flexbox support is limited. The first layout came out wrong. A table-based layout renders reliably, so invoice layouts use tables.
  • Dependencies need pinning. WeasyPrint 62.3 broke with a newer version of its PDF library pydyf. Pinning pydyf==0.10.0 fixed it.
  • Assets need care. The default logo is embedded from static files, so rendering doesn't depend on a network fetch.
  • Container images need system libraries. WeasyPrint relies on native libraries, and one base-image update needed a compatibility fix before builds worked again.

Why the server wins

  • The data is already there. The server has the invoice, the tenant, and the logo. The browser has to fetch them and then fight its own renderer.
  • One layout. An HTML template on the server is the single definition of what an invoice looks like.
  • Same output everywhere. Server rendering doesn't depend on the user's browser, fonts, or screen size.
  • It's reusable. The same endpoint can back a download button, an email attachment, or an admin tool.

The takeaway

Browser-side PDF libraries are fine for quick exports of simple content. For documents that must match a design and look identical for every user, like invoices, receipts, and certificates, generate them on the server and let the browser download a file. I'd start there next time instead of arriving there after three detours.

#pdf#nextjs#python#weasyprint#tailwind
Henry Iddirisu

Henry Iddirisu

AI product engineer · Accra, Ghana · Remote

Keep reading