reportgen
Back to all posts

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.

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 handlebars

The 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-size explicitly.
  • They render inside the page margin. If margin.bottom is 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-face inside 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:

  1. 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.
  2. Load fonts explicitly, either from a CDN like Google Fonts (as above) or, better for reliability, as @font-face with a base64 data URL inside the template, so there's no network request at all.
  3. 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 = true at the end of your script, then call await page.waitForFunction("window.renderDone === true") before page.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.