Written By Hanzala Saleem
Updated At August 03, 2026 | 8 min read
Every SaaS product eventually needs to send an invoice. The moment that requirement lands on your desk, the instinct is to reach for a PDF library, pdfkit, ReportLab, iText, something with a name that sounds official. Then you open the docs and remember what these libraries actually ask of you: place text at exact x/y coordinates, register fonts manually, calculate line wrapping yourself, and rebuild your entire layout logic if the design changes next quarter.
There is a simpler path. If you can build a web page, you already know how to build an invoice. HTML and CSS handle layout, fonts, tables, and spacing far better than any coordinate-based PDF API. The only piece you are missing is a way to turn that HTML into a PDF file, and that is a rendering problem, not a document-generation problem.
This guide walks through building that pipeline: HTML template in, PDF invoice out, with no PDF library and no headless browser to babysit.
A PDF library like pdfkit or ReportLab builds a PDF from scratch, instruction by instruction. You tell it to draw a rectangle here, place a string there, and it assembles those primitives into a valid PDF file. That approach works, but it means your invoice layout lives in imperative drawing code instead of a stylesheet.
The alternative is rendering. A real browser engine (Chromium) loads your HTML and CSS exactly the way it would in a browser tab, then prints that rendered page to a PDF, the same mechanism behind a browser's own "Print to PDF" feature. You get flexbox, CSS tables, custom fonts, and @media print rules for free, because you are not reimplementing a page layout engine. You are using the one already sitting in Chrome.
That is the approach this article covers: HTML and CSS as the source of truth, a rendering API as the conversion step.

Puppeteer and Playwright can absolutely render HTML to PDF, they are, after all, controlling a real browser too. wkhtmltopdf does something similar with an older WebKit build. The difference is what you are responsible for once that code ships to production.
| Method | Layout engine | Setup effort | Ongoing maintenance | Best for |
|---|---|---|---|---|
| PDF library (pdfkit, ReportLab, iText) | None (manual drawing) | Low to start | High as templates change | Simple, static layouts that rarely change |
| wkhtmltopdf | Old WebKit fork | Moderate | High (unmaintained, weak CSS support) | Legacy systems already using it |
| Self-hosted Puppeteer / Playwright | Full Chromium | High | High (memory leaks, Chrome CVEs, scaling) | Teams with dedicated infra to run headless Chrome |
| Managed rendering API | Full Chromium | Minutes | None | Teams that want HTML in, PDF out, nothing else |
Self-hosting Puppeteer means keeping a browser process alive somewhere. Each Chrome instance typically holds 300 to 500 MB of RAM, and idle or crashed processes can leak memory under production load. You end up writing orchestration code, launch flags, retry logic, --no-sandbox, --disable-dev-shm-usage, just to keep the renderer stable, before you have written a single line of invoice logic.
wkhtmltopdf avoids the browser-orchestration problem but trades it for a rendering one: it is built on an old WebKit fork with weak Flexbox and Grid support, and it has had no meaningful active development in years. Fine for a plain table-based layout from 2015, painful for anything modern.
A managed rendering API removes both problems. You send HTML, you get a PDF back. The browser lifecycle, memory management, and Chrome version patching happen on someone else's infrastructure.
The pipeline has three parts: build the template, inject the data, send it to a renderer. Here is each step using ScreenshotAPI's PDF rendering endpoint, which takes raw HTML through the custom_html parameter and returns a rendered PDF from a real Chromium instance.
Design the invoice exactly like a web page. A header with your company name, a customer block, a line-items table, and a totals section at the bottom.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; color: #1a1a1a; padding: 40px; }
.header { display: flex; justify-content: space-between; margin-bottom: 32px; }
.company { font-size: 22px; font-weight: bold; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th { background: #f3f4f6; text-align: left; padding: 10px; }
td { padding: 10px; border-bottom: 1px solid #e5e7eb; }
.total-row td { font-weight: bold; font-size: 16px; border-top: 2px solid #1a1a1a; }
</style>
</head>
<body>
<div class="header">
<div class="company">{{companyName}}</div>
<div>Invoice #{{invoiceNumber}}<br>{{invoiceDate}}</div>
</div>
<p>Billed to: {{customerName}}<br>{{customerEmail}}</p>
<table>
<tr><th>Description</th><th>Qty</th><th>Price</th><th>Amount</th></tr>
{{lineItemsHtml}}
<tr class="total-row"><td colspan="3">Total</td><td>{{totalAmount}}</td></tr>
</table>
</body>
</html>Notice there is no PDF-specific syntax anywhere in this file. It is a page you could open in a browser tab right now.
This is the step most tutorials skip, and it is the one that actually matters for a financial document: never let the browser or a template engine calculate money. Calculate subtotal, tax, and total in your backend, in a fixed-precision currency library, then inject only the final formatted strings into the template. If a client-side template accidentally rounds 19.995 to 20.00 on one render and 19.99 on another, you have a discrepancy on a legal document.
function buildInvoiceHtml(invoice) {
const rows = invoice.lineItems.map(item => `
<tr>
<td>${item.description}</td>
<td>${item.qty}</td>
<td>${formatCurrency(item.price, invoice.currency)}</td>
<td>${formatCurrency(item.qty * item.price, invoice.currency)}</td>
</tr>`).join("");
const total = invoice.lineItems.reduce((sum, i) => sum + i.qty * i.price, 0);
return template
.replace("{{companyName}}", invoice.companyName)
.replace("{{invoiceNumber}}", invoice.number)
.replace("{{invoiceDate}}", invoice.date)
.replace("{{customerName}}", invoice.customerName)
.replace("{{customerEmail}}", invoice.customerEmail)
.replace("{{lineItemsHtml}}", rows)
.replace("{{totalAmount}}", formatCurrency(total, invoice.currency));
}Once you have a finished HTML string, POST it to ScreenshotAPI with file_type=pdf. Because invoices are typically short HTML documents but can occasionally include several line items, a POST request avoids the character limits that a GET query string can hit.
const axios = require("axios");
async function generateInvoicePdf(invoice) {
const html = buildInvoiceHtml(invoice);
const response = await axios.post(
"https://shot.screenshotapi.net/v3/screenshot",
null,
{
params: {
token: process.env.SCREENSHOTAPI_TOKEN,
custom_html: html,
output: "image",
file_type: "pdf",
"pdf_options[format]": "A4",
"pdf_options[print_background]": true,
},
responseType: "arraybuffer",
}
);
return response.data; // raw PDF bytes, ready to email or save
}output=image returns the raw PDF bytes directly in the response body, so you can attach them to an email or write them to disk without an intermediate file or a second request to fetch the result. No width or height is set here on purpose: when neither a paper format nor a viewport size is provided, the PDF rendering documentation generates a single continuous page sized to the content, but setting pdf_options[format]=A4 here gives us a standard, printable invoice size instead.
Invoices are one of the few document types where paper format actually matters, someone might print this. ScreenshotAPI exposes this through PDF-specific options that sit alongside custom_html:
If you skip format entirely and also skip width/height, the API renders the invoice as one continuous page with no page breaks. That is useful for a receipt you only ever display on screen, but for anything a customer might print or archive as a standard document, set an explicit paper format.
Line-item counts vary. A five-line invoice fits on one A4 page; a fifty-line usage-based billing statement does not. Because the rendering engine is a real browser applying normal CSS page-break rules, you do not need special pagination logic, standard print CSS handles it:
tr { page-break-inside: avoid; }
.header { page-break-after: avoid; }Add this to your stylesheet and the browser engine keeps table rows from splitting awkwardly across a page boundary, the same rule you would use if you were printing the page from a browser's File > Print dialog.
Invoices are financial records, most businesses need to keep them for years, not just email them once and forget them. Rather than storing PDFs on your own application server, ScreenshotAPI can write the rendered output directly to a cloud bucket you control, using its storage integration.
After connecting Amazon S3, Google Cloud Storage, or Wasabi in your dashboard, add the byob (bring your own bucket) parameter to the same request:
params: {
token: process.env.SCREENSHOTAPI_TOKEN,
custom_html: html,
file_type: "pdf",
"pdf_options[format]": "A4",
byob: true,
storage_service: "aws",
bucket_name: "company-invoices",
}The PDF lands in your own bucket at generation time, no separate upload step, no temporary local file, and no invoice data passing through a third-party's long-term storage.
| Approach | Coding effort | Layout control | Handles Flexbox/Grid | Print-ready pagination |
|---|---|---|---|---|
| PDF library (coordinate-based) | High | Manual, pixel by pixel | No | Manual |
| wkhtmltopdf | Moderate | CSS-based, dated engine | Partial | Basic |
| Self-hosted Puppeteer | Moderate to high | Full CSS | Yes | Yes (but you maintain the browser) |
Rendering API (custom_html) | Low | Full CSS | Yes | Yes |
The pattern that shows up across every serious invoicing pipeline is the same: template in HTML, calculate money server-side, render with a real browser engine, store the output somewhere durable. The only variable is who runs that browser engine, you, or a managed service.
If your invoice pipeline also needs to generate other rendered assets, like an OG-image style receipt preview or a certificate, the custom_html blog post covers the parameter in more depth, including PNG output for on-screen previews.
Yes. The custom_html parameter accepts an HTML string directly, so the invoice template never needs to exist as a publicly reachable page. This is the standard approach for invoices, since the HTML is usually generated dynamically per customer and per billing cycle rather than hosted anywhere.
No. The rendering API handles the HTML-to-PDF conversion entirely. Your code only needs to build an HTML string and calculate the invoice totals, both things you would do for any web page.
A4 for most of the world, Letter for the US and Canada. Set this explicitly with pdf_options[format]; if you leave both the format and viewport dimensions unset, the PDF renders as one continuous page instead of a standard printable size.
Calculate the subtotal, tax, and total in your backend using a fixed-precision currency library, not floating-point math or client-side JavaScript, then inject only the final formatted values into the HTML template. The rendering step should never be responsible for arithmetic.