Page Numbers, Headers and Footers in HTML-to-PDF: Why Puppeteer's headerTemplate Fights You

You add a footerTemplate to your Puppeteer page.pdf() call, pass pageNumber and totalPages, and the result is either invisible text, a footer stamped over your…

You add a footerTemplate to your Puppeteer page.pdf() call, pass pageNumber and totalPages, and the result is either invisible text, a footer stamped over your content, or nothing at all. The template syntax looks straightforward, but Chromium's Skia PDF layer renders each header and footer as its own isolated mini-document with no CSS inheritance, no external assets, and five magic classes that only look simple [1] [2].

The template engine does not inherit your page CSS, does not load external assets, and requires inline styles for even basic visibility. A single missing font-size or an unencoded image fails silently, which makes these problems look like configuration errors when they are simply how the engine behaves.

The header and footer templates are not children of your page. Chromium's PDF generation path handles them as separate rendering documents through the DevTools Protocol's Page.printToPDF, which means your page CSS does not apply to them [1] [2]. Without an explicit font-size, template text renders at roughly 0.75pt — effectively invisible [1].

Zero default margins compound the problem. Puppeteer's page.pdf() uses no margins unless you specify them, so footers print directly over your body content [1]. In one measurement, body text ran from 6pt to 833pt down an 842pt page with no margins, straight through where headers and footers should sit. Adding 20mm top and bottom margins pushed the body to 62pt–782pt, leaving room for header and footer elements [1].

If you set only footerTemplate without headerTemplate, Chrome auto-fills the header with the date on the left and the document title in the center. An empty string does not suppress this behavior, but an empty element like <span></span> does [1].

What actually works inside a template

Five injection classes are available and no others: pageNumber, totalPages, title, date, and url [1]. The url class prints about:blank when the page was rendered with setContent() rather than loaded from a URL [1].

Images must be base64 data URIs. A PNG loaded from a hosted URL will not appear in the PDF, while the same image as a data:image/png;base64,... URI will [1]. Background colors need -webkit-print-color-adjust: exact inside the template itself; printBackground: true on the page options does not cover template rendering [1]. Web fonts loaded by your page are unavailable to templates; only system fonts already present in the browser environment work [1]. Script tags are not evaluated, and page styles remain invisible to the template [2].

This means every template must carry its own complete CSS, either inline or in a <style> block, with explicit font sizes, color-adjust declarations for backgrounds, and base64-encoded images.

The workaround culture and why it persists

GitHub issue #1822, opened January 17, 2018 against Puppeteer 1.0.0, reported that headerTemplate and footerTemplate did not work in PDF export [3]. Issue #5345, opened January 27, 2020, documented the invisible text problem and the need for inline CSS; it stayed active into 2024 [4]. Users in that thread found that launching the browser with --lang=en-US could restore pagination behavior, linked to an upstream chromedp issue, with confirmations as late as January 2024 [4]. After Puppeteer 22.2.0, Debian server deployments saw pageNumber and totalPages rendering in white text, reported February 2024 [4].

puppeteer-report by PejmanNik works around template limits by generating two separate PDFs — one for content, one for headers and footers repeated per page — then merging them [2]. This is a robust approach, but it adds dependency weight and merge complexity for what the API appears to promise natively.

Chrome 131's alternative: CSS page margin boxes

Chrome 131 added support for CSS page margin boxes using @top-right, @bottom-center, and similar at-rules, with counter(page) and counter(pages) for page numbering [1]. The @page :first pseudo-class allows a different first page header or footer, something the template approach cannot do [1].

Margin boxes hold generated content only — text, counters, simple strings — so they suit basic page numbers and dates. For a logo, multi-part layout, or styled running header, the template approach remains necessary [1]. Support is limited to Chromium 131+ and Safari 18.2; Firefox has not implemented these rules [1].

How a hosted converter handles the same engine without the debugging

MarkupGo's /pdf endpoint accepts header and footer as complete HTML documents using the same five injection classes: date, title, url, pageNumber, and totalPages [5] [5] [5]. The same underlying limitations apply because it uses the same Chromium engine — CSS is independent of the main document, footer CSS overrides header CSS, only installed fonts load, images must be base64 data URIs, and -webkit-print-color-adjust: exact is required for background colors [5] [5] [5].

Margins are set via the properties object in millimeters rather than passed through Puppeteer's option spread [5]:

{
  "source": {
    "type": "html",
    "data": "<h1>Invoice #1024</h1><p>...</p>"
  },
  "options": {
    "header": "<html><head><style>body{font-size:10px;margin:auto 20px;}</style></head><body><p>Company Name</p></body></html>",
    "footer": "<html><head><style>body{font-size:10px;margin:auto 20px;}</style></head><body><p>Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></p></body></html>",
    "properties": {
      "margins": {
        "top": 20,
        "bottom": 20,
        "left": 15,
        "right": 15
      }
    }
  }
}

preferCssPageSize: true passes @page margin-box CSS through unchanged for browsers that support it, letting you use Chrome 131's native margin boxes where appropriate [5] [1]. The endpoint accepts the same structure for URL, HTML, template, or markdown sources, with optional expiration for automatic cleanup [5] [5].

Generate a test PDF with explicit margins and a base64-encoded logo in your footer template, or switch to CSS margin boxes if your target browsers support them and your layout needs are simple.

Sources

  1. Puppeteer PDF headers and footers with page numbers: 7 traps (dev.to)
  2. stackoverflow.com
  3. github.com
  4. github.com
  5. markupgo.com