Multi-page PDF best practices: headers, footers, and page numbers

A practical guide to designing headers, footers, and page numbering for multi-page PDFs — covering layout math, repeating elements, and common rendering pitfalls.

pdfhtml-to-pdfnode.jsheadersfootersdocument-generation

Multi-page PDF best practices: headers, footers, and page numbers

Generating a single-page PDF is easy. Generating a 40-page invoice run or a contract with repeating headers, page numbers, and consistent margins is where most PDF pipelines start to break. Content overflows into the footer. Page numbers restart in weird places. The header disappears on page two because it was positioned absolutely in the body flow.

This post covers the layout rules and implementation patterns that keep multi-page PDFs clean, no matter which renderer you're using.

Think in terms of a page box, not a canvas

Most developers coming from web development treat the PDF as one long scrollable page. It isn't. A PDF is a sequence of fixed-size page boxes, and every page has:

  • A page size (A4, Letter, etc.)
  • Margins that define the content area
  • A header region and footer region that live inside those margins
  • A body region where paginated content flows

Before writing a single line of template code, decide the numbers. A reasonable A4 default:

txt
Page size: 210mm x 297mm Margins: 20mm top, 20mm bottom, 15mm left, 15mm right Header height: 15mm (inside top margin) Footer height: 10mm (inside bottom margin) Body: everything in between

The key constraint: your header and footer must fit inside the page margins. If your top margin is 20mm and your header is 25mm tall, your body content will render underneath the header on every page.

Headers and footers should be declared separately from the body

The single biggest mistake is putting headers and footers inside the body HTML and hoping CSS position: fixed will replicate them. It won't — not reliably across renderers, and definitely not with correct page numbering.

Chromium-based PDF engines (Puppeteer, Playwright, headless Chrome) expect header and footer templates as separate HTML strings:

ts
await page.pdf({ format: "A4", displayHeaderFooter: true, margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" }, headerTemplate: ` <div style="font-size:9px; width:100%; padding:0 15mm; color:#666;"> <span>Invoice #<span class="title"></span></span> </div> `, footerTemplate: ` <div style="font-size:9px; width:100%; padding:0 15mm; color:#666; text-align:right;"> Page <span class="pageNumber"></span> of <span class="totalPages"></span> </div> `, });

A few gotchas worth memorizing:

  • Header/footer templates ignore the main document's CSS. Inline every style you need.
  • The default font size inside headers/footers is tiny (often 0px). Always set font-size explicitly.
  • The template is rendered at the page's full width, including the horizontal margins. Add your own horizontal padding to align with body content.
  • Only a specific set of classes work for dynamic values: pageNumber, totalPages, date, title, url.

With Kamy, the same thing is expressed declaratively in the template config so you don't manage browser internals:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2041", customer: "Acme Co." }, layout: { format: "A4", margin: { top: 20, bottom: 20, left: 15, right: 15 }, header: { template: "invoice-header", height: 15 }, footer: { template: "invoice-footer", height: 10, showPageNumbers: true }, }, });

Page numbers: the three patterns worth knowing

Page numbering has three common formats. Each has a different implementation cost.

1. Simple page X / Y. The default. Chromium exposes it directly via pageNumber and totalPages. Works everywhere.

2. Page X of Y, excluding cover. You usually want the cover page to display no number, and page 2 to show "1 of N". This requires post-processing: render the full document first, then overlay the numbers using a PDF library like pdf-lib:

ts
import { PDFDocument, StandardFonts } from "pdf-lib"; import fs from "node:fs/promises"; const bytes = await fs.readFile("output.pdf"); const doc = await PDFDocument.load(bytes); const font = await doc.embedFont(StandardFonts.Helvetica); const pages = doc.getPages(); const contentPages = pages.slice(1); // skip cover contentPages.forEach((page, i) => { const { width } = page.getSize(); page.drawText(`${i + 1} of ${contentPages.length}`, { x: width - 60, y: 20, size: 9, font, }); }); await fs.writeFile("final.pdf", await doc.save());

3. Per-section numbering. Contracts and reports often restart numbering per chapter (A-1, A-2, B-1, B-2). This almost always requires generating each section as its own PDF and merging — Chromium's header template has no concept of sections.

Keep content from colliding with headers and footers

Even with correct margins, long tables and images can push into header/footer space because the renderer makes paginations decisions based on CSS, not visual overlap. Three rules prevent 90% of the issues:

css
/* Avoid splitting rows across pages */ tr { page-break-inside: avoid; } /* Keep headings with the content that follows */ h2, h3 { page-break-after: avoid; } /* Force a new page before major sections */ .section-break { page-break-before: always;