Page numbers in Puppeteer PDFs

Tested: Puppeteer 23 with Chrome 127 and Chrome 159, A4, 2026-10-30

There are two ways to get "Page 1 of 4" into a Puppeteer PDF:

  1. CSS @page margin boxes. This is plain CSS with no Puppeteer options, and it needs Chrome 131 or later.
  2. footerTemplate. It works in any version, but it has four gotchas that make footers vanish or overlap the content.

Check which Chrome you're running:

console.log(await browser.version()); // margin boxes need 131 or higher

Option 1: CSS margin boxes (Chrome 131+)

@page {
  size: A4;
  margin: 20mm 15mm;
  @top-left      { content: "Acme Inc. ยท Invoice 1042"; font-size: 9pt; }
  @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; }
}
await page.setContent(html);
await page.pdf({ format: 'A4' }); // no header/footer options needed

In our test with Chrome 159 this printed "Page 1 of 4" through "Page 4 of 4". It worked with the default page.pdf() options, with preferCSSPageSize: true, and with a Puppeteer margin set. Chrome 127 ignored the margin boxes without any error and printed nothing. So if this "doesn't work", check the version first. Older Puppeteer releases bundle older Chrome, and so do many Docker images.

This approach keeps the footer in the same stylesheet as the page, so your fonts and styles apply as normal.

Option 2: footerTemplate (any Chrome)

await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate: '<span></span>',
  footerTemplate: `
    <div style="font-size:9px; width:100%; text-align:center;">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span>
    </div>`,
  margin: { top: '15mm', bottom: '20mm' },
});

Chrome fills in elements with these class names: pageNumber, totalPages, date, title and url.

Each line in that snippet is there for a reason. We reproduced every one of these in both Chrome versions:

1. The footer is invisible because it has no font size. Without an explicit font-size, the template text rendered at about 0.7pt, too small to see. With font-size: 9px it rendered at a normal 6.75pt. Always set a font size on the template.

2. The footer prints on top of your content because there's no margin. With no bottom margin, the footer sat 16pt from the bottom edge while body text ran down to 6pt, so they overlapped. The footer is drawn inside the page margin, so give it room: margin.bottom of about 20mm fits one line.

3. Your page's CSS doesn't reach the template. A .brand { color: red } rule turned the body text red but left the footer black. The template is rendered separately, so use inline styles inside it.

4. Images by URL don't load. A logo given by http:// URL didn't render at all. The same image as a data:image/png;base64,... URI did. Inline your logo as base64.

Plus one surprise: if you set displayHeaderFooter: true and only pass a footerTemplate, Chrome adds its default header with the date and the document title. Pass headerTemplate: '<span></span>' to turn it off.

What Chrome can't do: a table of contents with page numbers

CSS has a function for this, target-counter(attr(href), page), but Chrome doesn't support it. In our test it printed nothing in either version. You have three options:

Which one to use

CSS margin boxes footerTemplate
Chrome version 131+ any
Styling your normal CSS inline styles only
Images normal base64 only
"Page X of Y" counter(page) / counter(pages) pageNumber / totalPages

If you control the Chrome version, use margin boxes. If you don't (shared Docker images, old Lambda layers), use footerTemplate with an explicit font size, a bottom margin, inline styles and base64 images.

Don't want to run Chrome yourself? We're building a small HTML-to-PDF API with page numbers, repeating table headers and matching fonts on by default.

We'll email you when the API is ready to try. Privacy

More guides