Multi-page PDF Best Practices: Headers, Footers, and Page Numbers

Headers, footers, and page numbers seem trivial until you generate your first 50-page invoice. Here's how to do them right in programmatic PDFs.

pdfhtml-to-pdfnode.jsdocument-generationcss

Multi-page PDF Best Practices: Headers, Footers, and Page Numbers

Generating a single-page PDF is easy. Generating a clean 50-page report — with running headers, footers that align to the page edge, page numbers that say "Page 3 of 47", and content that doesn't get sliced in half — is where most PDF pipelines fall apart.

This post covers the patterns that actually hold up in production: how to structure repeating page elements, how to handle "Page X of Y", and how to avoid the layout bugs that only show up on page 12.

Why multi-page PDFs are different

Most HTML-to-PDF engines (Chromium's print pipeline, WeasyPrint, Prince, and the engine behind Kamy) work in two stages:

  1. Render the HTML into a continuous flow.
  2. Break that flow into pages using CSS paged media rules.

This means a <header> inside your body renders once, not on every page. To repeat content on every page, you need paged-media CSS — specifically @page margin boxes — or engine-specific header/footer templates.

Get this wrong and you'll see headers floating only on page 1, footers overlapping content, or page numbers that say "Page NaN of undefined".

Use @page margin boxes for repeating content

The cleanest, most portable approach is CSS paged media. Define the page size, margins, and margin boxes once:

css
@page { size: A4; margin: 25mm 20mm 25mm 20mm; @top-left { content: "Acme Corp — Q4 Report"; font-size: 9pt; color: #666; } @top-right { content: "Confidential"; font-size: 9pt; color: #666; } @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #666; } }

counter(page) and counter(pages) are built into the paged-media spec and resolve to the current page number and total page count. No JavaScript, no second pass.

If you need a different first page (e.g., a cover with no header), use @page :first:

css
@page :first { @top-left { content: ""; } @top-right { content: ""; } @bottom-center { content: ""; } }

Headless Chrome / Puppeteer: use headerTemplate and footerTemplate

If you're driving Chromium directly, @page margin boxes aren't supported. You need the template options:

ts
const pdf = await page.pdf({ format: "A4", margin: { top: "25mm", bottom: "25mm", left: "20mm", right: "20mm" }, displayHeaderFooter: true, headerTemplate: ` <div style="font-size:9px; width:100%; padding:0 20mm; color:#666; display:flex; justify-content:space-between;"> <span>Acme Corp — Q4 Report</span> <span>Confidential</span> </div> `, footerTemplate: ` <div style="font-size:9px; width:100%; padding:0 20mm; text-align:center; color:#666;"> Page <span class="pageNumber"></span> of <span class="totalPages"></span> </div> `, });

Two gotchas worth pinning to your monitor:

  • Chromium ignores external CSS and most font declarations inside these templates. Inline everything.
  • margin.top and margin.bottom must be larger than the header/footer height, or content will overlap.

Avoid bad page breaks

Even with perfect headers and footers, content split awkwardly across pages looks broken. Use CSS break hints:

css
h1, h2, h3 { break-after: avoid; } table, figure, .invoice-line { break-inside: avoid; } .page-break { break-after: page; }

For long tables, repeat the header row on every page:

css
thead { display: table-header-group; } tfoot { display: table-footer-group; }

This is the single biggest visual upgrade for any report or invoice longer than one page.

Page-specific content (cover, TOC, appendix)

For documents with structural sections, use named pages:

css
@page cover { margin: 0; @bottom-center { content: ""; } } .cover { page: cover; break-after: page; }

Then in your HTML:

html
<section class="cover"> <h1>Q4 Report</h1> </section> <section class="body"> <!-- normal pages with headers/footers --> </section>

Using Kamy

If you'd rather not maintain a Chromium pool or debug paged-media quirks, Kamy renders multi-page PDFs from HTML templates with @page rules fully supported. You send the template and data, and Kamy returns a finished PDF:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "quarterly-report", data: { title: "Q4 2024 Report", sections: reportSections, }, }); console.log(pdf.url);

Counters, repeated table headers, and :first page rules all behave as the spec describes — so the same CSS you'd write for WeasyPrint or Prince works as-is.

A checklist before you ship

Before declaring your PDF pipeline done, generate a document that's at least 20 pages and verify:

  • Headers and footers appear on every page (or every page except the cover).
  • "Page X of Y" shows correct totals, not "Page 1 of 1" repeated.
  • Page margins are larger than header/footer heights.
  • Tables repeat their header row across page bre