Multi-page PDF best practices: headers, footers, and page numbers
Generating a one-page PDF is easy. Generating a 40-page invoice with a repeating header, a footer with legal text, and correct "Page 3 of 40" numbering — that's where most PDF pipelines start leaking bugs. Rows get cut in half, footers overlap content, page numbers reset on section breaks, and the layout you saw in Chrome doesn't match what the renderer produces.
This post covers the patterns that hold up in production, regardless of whether you're using Puppeteer, wkhtmltopdf, a native library like ReportLab, or an API like Kamy.
Reserve space for headers and footers explicitly
The single most common bug in multi-page PDFs: content flows underneath the header or footer because the page margins weren't reserved for them.
Headers and footers live in a separate layer from the main content flow. If your header is 60px tall, your top margin needs to be at least 60px — otherwise the first line of body content on every page renders under the header.
When using Chromium-based rendering (Puppeteer, Playwright, or Kamy under the hood), set margins that account for the header/footer height:
tsawait page.pdf({ format: "A4", displayHeaderFooter: true, headerTemplate: `<div style="font-size:10px; width:100%; text-align:center;">Acme Corp — Invoice</div>`, footerTemplate: `<div style="font-size:9px; width:100%; text-align:center;"> Page <span class="pageNumber"></span> of <span class="totalPages"></span> </div>`, margin: { top: "80px", bottom: "60px", left: "40px", right: "40px", }, });
Rule of thumb: header/footer height + 20px of breathing room = your top/bottom margin.
Page numbers: use the renderer's built-in variables
Don't try to compute page numbers yourself in application code. You don't know where the renderer will break pages until it actually paginates. Every serious PDF engine exposes page-number variables you can inject into the header or footer template.
For Chromium:
html<div style="font-size: 9px; width: 100%; padding: 0 40px; display: flex; justify-content: space-between;"> <span>Confidential</span> <span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span> </div>
The .pageNumber and .totalPages classes are populated at render time. Same idea in wkhtmltopdf ([page] and [topage]) and in most native libraries.
If you're using Kamy, the template engine exposes {{ page.number }} and {{ page.total }} inside header and footer blocks:
html<footer> <span>{{ company.name }}</span> <span>Page {{ page.number }} of {{ page.total }}</span> </footer>
Handle page breaks in tables and long content
Long tables are the second-biggest source of ugly PDFs. Rows get split across pages, headers don't repeat, and section titles end up as orphans at the bottom of a page.
Use CSS to control breaks:
csstable { border-collapse: collapse; width: 100%; } /* Repeat table headers on every page */ thead { display: table-header-group; } tfoot { display: table-footer-group; } /* Don't split a row across pages */ tr { break-inside: avoid; page-break-inside: avoid; } /* Keep a heading with the content that follows it */ h2, h3 { break-after: avoid; page-break-after: avoid; } /* Force a new page before a major section */ .section-break { break-before: page; page-break-before: always; }
display: table-header-group on <thead> is the underrated one — it makes browser-based renderers repeat the header row on every page a table spans.
Keep headers and footers stateless
A common mistake: putting dynamic per-page data (like "current section title") in the header. Unless your renderer supports running headers with per-page context — most don't, cleanly — assume the header/footer template runs once and is reused for every page.
Data that's safe to put in headers/footers:
- Company name, logo
- Document title, invoice number, date
- Page numbers, total pages
- Static legal text
Data that will bite you:
- Current chapter or section (requires running-header support)
- Per-page conditional content
- Anything derived from the body content on that specific page
If you truly need running headers, use a library that supports them (Prince, WeasyPrint, or LaTeX) rather than fighting Chromium.
Test at the boundaries
Every multi-page PDF bug I've seen in production shows up at a boundary:
- Content that's exactly one page long (does an empty page 2 appear?)
- Content that's slightly over one page (widows and orphans)
- Very long tables (does the header repeat correctly?)
- Empty state (0 line items — does the footer still render?)
Write fixture tests with these exact shapes. A good pattern:
tsconst fixtures = [ { name: "empty", items: [] }, { name: "one-page", items: generateItems(8) }, { name: "page-boundary", items: generateItems(24) }, { name: "multi-page", items: generateItems(200) }, ]; for (const fx of fixtures) { const pdf = await renderInvoice(fx); await fs.writeFile(`./snapshots/${fx.name}.pdf`, pdf); }
Diff the output visually (tools like pdftoppm + image diff work well) as part of CI.
Summary
Multi-page PDFs work when you treat pagination as a first-class conc