Build Your Own PDF API, Part 2: Templates, Page Numbers and Page Breaks
November 2, 2026
Part 2 of 6 of Build Your Own ReportGen.
- Part 1: A Minimal Service
- Part 2: Templates, Page Numbers and Page Breaks (you are here)
- Part 3: Async Jobs, Storage and Webhooks
- Part 4: Running Chrome in Production
- Part 5: Security
- Part 6: Docker, Hosting and Scaling
In Part 1 we sent finished HTML to Chrome. In practice you have a template (the invoice layout) and data (this customer's line items), and the PDF is the combination.
This part covers the two halves of getting that right: rendering templates, and making multi-page output look professional.
Choosing a Template Engine
reportgen.io supported four engines. If you're building your own, pick one:
| Engine | Pick it when |
|---|---|
| Handlebars | Templates are edited by designers or non-developers. Logic-light, safe, helpers for formatting. |
| EJS | Only developers touch templates and you want plain JavaScript inside them. |
| Your framework's | You already render HTML in React, Jinja, Go templates, Blade... Render there and send finished HTML to the PDF service. |
We'll use Handlebars. Its limited logic is a feature: templates can't run arbitrary code on your server.
npm install handlebarsThe Renderer
// render.js — template + JSON -> HTML -> PDF
const Handlebars = require("handlebars");
const crypto = require("crypto");
const hbs = Handlebars.create(); // isolated instance: helpers don't leak globally
hbs.registerHelper("money", (amount, currency) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: currency || "EUR" }).format(amount)
);
hbs.registerHelper("date", (iso) =>
new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
);
// Compiling is the slow part. Cache compiled templates by content hash.
const cache = new Map();
function compile(source) {
const key = crypto.createHash("sha256").update(source).digest("hex");
if (!cache.has(key)) {
if (cache.size > 500) cache.delete(cache.keys().next().value); // crude LRU cap
cache.set(key, hbs.compile(source, { strict: true })); // strict: missing fields throw
}
return cache.get(key);
}
function renderHtml(template, data) {
return compile(template)(data);
}
// Header/footer templates run in a separate, tiny document:
// no access to your page CSS, so styles must be inline, and font-size
// defaults to almost nothing. Chrome fills the special classes for you.
const footerTemplate = `
<div style="width:100%; font-size:9px; color:#6b7280; padding:0 15mm;
display:flex; justify-content:space-between;">
<span class="title"></span>
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>`;
// Prints HTML on a page you already have. Part 4 hands it pages from a pool.
async function printPdf(page, html, opts = {}) {
await page.setContent(html, { waitUntil: "networkidle0", timeout: 30_000 });
await page.evaluateHandle("document.fonts.ready"); // don't print before webfonts load
return Buffer.from(
await page.pdf({
format: opts.format || "A4",
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>", // empty header, or Chrome prints the date + title
footerTemplate,
// Margins must leave room for the footer, or it renders under your content.
margin: { top: "18mm", bottom: "22mm", left: "15mm", right: "15mm" },
})
);
}
// Opens a fresh page, prints, and always closes the page again.
async function renderPdf(browser, html, opts = {}) {
const page = await browser.newPage();
try {
return await printPdf(page, html, opts);
} finally {
await page.close();
}
}
module.exports = { renderHtml, printPdf, renderPdf };The renderer is split in two: printPdf prints on a page you hand it, and renderPdf opens and closes that page for you. That split pays off in Part 4, where pages come from a pool.
Three choices in there are worth explaining.
strict: true. By default, Handlebars renders a missing field as an empty string. That means a typo like {{custmer.name}} silently produces an invoice with no customer name, and nobody notices until a customer complains. Strict mode throws instead, so bad data fails loudly before a wrong PDF goes out.
The compile cache. Compiling a template is much slower than running it. Most apps use a handful of templates thousands of times, so cache compiled functions by a hash of the source.
document.fonts.ready. networkidle0 waits for the font file to download, but the browser may not have finished applying it. Waiting on document.fonts.ready avoids PDFs that randomly come out in a fallback font.
Headers, Footers and Page Numbers
Chrome's displayHeaderFooter option prints a small HTML snippet at the top and bottom of every page, and fills in these classes:
| Class | Becomes |
|---|---|
pageNumber |
Current page |
totalPages |
Total page count |
title |
The document's <title> |
date |
Print date |
url |
Document URL (useless for setContent) |
The traps, all of which we hit:
- Header and footer templates can't see your page CSS. They render in a separate document. Use inline styles only.
- The default font size is tiny (it looks like ~1px). Always set
font-sizeexplicitly. - They render inside the page margin. If
margin.bottomis smaller than your footer, the footer overlaps your content or vanishes. - If you don't pass
headerTemplate, Chrome adds a default one with the date and title. Pass an empty<span></span>to suppress it. - Web fonts don't load in headers/footers. Use a system font or embed the font as a base64
@font-faceinside the snippet.
A Template That Paginates Properly
Here's an invoice template. The CSS comments are the important part:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Invoice {{number}}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
@page { size: A4; }
body { font-family: "Inter", sans-serif; color: #111827; font-size: 12px; }
h1 { font-size: 22px; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
thead { display: table-header-group; } /* repeat the header row on every page */
tr { break-inside: avoid; } /* never split a row across pages */
th, td { padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: left; }
td.num, th.num { text-align: right; }
.totals { break-inside: avoid; margin-top: 16px; text-align: right; font-weight: 600; }
.page-break { break-before: page; }
</style>
</head>
<body>
<h1>Invoice {{number}}</h1>
<div>{{customer.name}} · Issued {{date issuedAt}}</div>
<table>
<thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Price</th></tr></thead>
<tbody>
{{#each items}}
<tr><td>{{name}}</td><td class="num">{{qty}}</td><td class="num">{{money price ../currency}}</td></tr>
{{/each}}
</tbody>
</table>
<div class="totals">Total: {{money total currency}}</div>
<div class="page-break"></div>
<h2>Terms</h2>
<p>Payment due within 30 days.</p>
</body>
</html>And rendering it:
const fs = require("fs");
const puppeteer = require("puppeteer");
const { renderHtml, renderPdf } = require("./render");
const browser = await puppeteer.launch();
const html = renderHtml(fs.readFileSync("invoice.hbs", "utf8"), {
number: "INV-2041",
currency: "EUR",
issuedAt: "2026-11-02",
customer: { name: "Acme d.o.o." },
items: [{ name: "Consulting", qty: 3, price: 120 }],
total: 360,
});
fs.writeFileSync("invoice.pdf", await renderPdf(browser, html));With 60 line items, this produces a 4-page PDF where the table header repeats at the top of every page, no row is cut in half, the total never gets orphaned from the table, the terms start on a fresh page, and every page says "Page N of 4" in the footer.
The pagination CSS cheat sheet
| Goal | CSS |
|---|---|
| Start an element on a new page | break-before: page |
| Keep an element in one piece | break-inside: avoid |
| Repeat table headers on every page | thead { display: table-header-group } |
| Keep a heading with the paragraph after it | h2 { break-after: avoid } |
| Avoid lonely lines at page edges | p { orphans: 3; widows: 3 } |
| Set page size and orientation in CSS | @page { size: A4 landscape } |
Two gotchas: break-inside: avoid doesn't work on elements with display: flex or grid in all cases, so wrap them in a plain block div. And an element taller than a page will still break, because there's nowhere else for it to go.
Fonts
Fonts cause more "the PDF looks wrong" reports than anything else. The rules:
- Never rely on the server's system fonts. Your laptop has Helvetica; your Docker container has almost nothing. Text silently falls back to a different font.
- Load fonts explicitly, either from a CDN like Google Fonts (as above) or, better for reliability, as
@font-facewith a base64 data URL inside the template, so there's no network request at all. - Install fallback fonts for other scripts in your container: CJK, Arabic, emoji. Otherwise a customer named 王 or an emoji in a product name renders as empty boxes. The Part 6 Dockerfile handles this.
Charts
If your template draws charts with JavaScript (Chart.js, ECharts), make sure they finish before you print:
- Turn off animations. Otherwise you print a half-drawn chart. In Chart.js:
options: { animation: false }. - Signal when done for anything async. Set
window.renderDone = trueat the end of your script, then callawait page.waitForFunction("window.renderDone === true")beforepage.pdf().
Frequently Asked Questions
Should templates live in my database or in my repo? In your repo if developers own them. You get code review and history for free. If customers edit their own templates, store them in the database with a version number, and save the version used with each generated PDF so you can reproduce it later.
How do I preview templates while designing them? Open the rendered HTML in Chrome and use DevTools' "Emulate CSS media type: print" (in the Rendering panel). It's almost identical to the PDF output and much faster to iterate on.
Letter or A4? Let the request decide. Default to the one most of your users expect: Letter for US customers, A4 for nearly everyone else.
Next: Part 3: Async Jobs, Storage and Webhooks. Some PDFs take longer than an HTTP request should, so we'll move rendering into background workers backed by plain Postgres.