HTML to PDF with page numbers, headers and footers

Most HTML-to-PDF code gets page numbers wrong in the same few ways: the footer is invisible, overlaps the text, or shows about:blank. Chrome now supports the CSS way of doing it, so a few lines in your stylesheet give you "Page 3 of 12" and a running header in any Chromium-based renderer, and WeasyPrint reads the same CSS. Below: the CSS, tested Node and Python scripts, and a fixed Puppeteer headerTemplate for when you can't change the page.

Why the footer is missing: Puppeteer's templates, measured

We printed a 60-paragraph page with Chromium's displayHeaderFooter (the option behind Puppeteer's and Playwright's page.pdf()) and measured the text in the PDF:

What you seeCause and fix
Footer text is a thin line or invisibleTemplates don't inherit the page's CSS. With no font size, our "Page 1 of 3" came out 0.87 pt tall.
Fix: inline font-size:9px (and width, padding) in the template
Footer drawn on top of the textTemplates are drawn inside the page margin. With no margin, body text ran to 13 pt above the footer line.
Fix: margin: { top: '25mm', bottom: '25mm' }
Date, about:blank and "1/3"displayHeaderFooter: true with no templates prints Chromium's defaults: date, title, URL, page count.
Fix: pass both templates (an empty <span></span> hides one)

The CSS way: @page margin boxes

Since Chrome 131 (late 2024), Chromium supports CSS page margin boxes: 16 named areas around each page (@top-left, @top-center, @bottom-right…) whose content can use counter(page) and counter(pages). The header and footer then live in your stylesheet, next to the content they belong to, and no renderer options are needed.

@page {
  size: A4;
  margin: 25mm 15mm;          /* the header and footer live inside this margin */

  @top-left {
    content: "ACME Corp · Quarterly report";
    font: 9pt Arial, sans-serif;
    color: #666;
  }
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
    font: 9pt Arial, sans-serif;
    color: #666;
  }
}

/* No header on the cover page */
@page :first {
  @top-left { content: none; }
}

@page :first targets the cover page only. The same CSS works in a browser's Print dialog, Puppeteer, Playwright and our API, and WeasyPrint has supported it for years (we checked it on WeasyPrint 67: same "Page 1 of 3" output).

Node.js: a report with page numbers

This builds a four-page invoice list, sends the HTML to POST /v1/pdf and saves report.pdf. Node 18+, no dependencies. Run it with SHOTMINT_KEY=... node report.mjs.

// report.mjs: a multi-page report with a running header and "Page X of Y" footer
import { writeFile } from 'node:fs/promises';

const rows = Array.from({ length: 120 }, (_, i) => `<tr><td>INV-${1001 + i}</td><td>€${(i * 37 % 900 + 100).toFixed(2)}</td></tr>`).join('');

const html = `<!doctype html><html><head><meta charset="utf-8"><style>
  @page {
    size: A4;
    margin: 25mm 15mm;
    @top-left { content: "ACME Corp · Quarterly report"; font: 9pt Arial, sans-serif; color: #666; }
    @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt Arial, sans-serif; color: #666; }
  }
  @page :first { @top-left { content: none; } }
  body { font: 11pt Arial, sans-serif; }
  table { width: 100%; border-collapse: collapse; }
  td { border-bottom: 1px solid #ddd; padding: 4px; }
</style></head><body>
  <h1>Quarterly report</h1>
  <table>${rows}</table>
</body></html>`;

const res = await fetch('https://shotmint.creasework.com/v1/pdf', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ html, format: 'A4' }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile('report.pdf', Buffer.from(await res.arrayBuffer()));
console.log('saved report.pdf, credits left:', res.headers.get('x-credits-remaining'));

Python

The same report with requests. Note the doubled braces: the CSS sits inside an f-string.

# report.py: same report from Python (pip install requests)
import os
import requests

rows = "".join(f"<tr><td>INV-{1001 + i}</td><td>€{i * 37 % 900 + 100:.2f}</td></tr>" for i in range(120))

html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
  @page {{
    size: A4;
    margin: 25mm 15mm;
    @top-left {{ content: "ACME Corp · Quarterly report"; font: 9pt Arial, sans-serif; color: #666; }}
    @bottom-right {{ content: "Page " counter(page) " of " counter(pages); font: 9pt Arial, sans-serif; color: #666; }}
  }}
  @page :first {{ @top-left {{ content: none; }} }}
  body {{ font: 11pt Arial, sans-serif; }}
  table {{ width: 100%; border-collapse: collapse; }}
  td {{ border-bottom: 1px solid #ddd; padding: 4px; }}
</style></head><body>
  <h1>Quarterly report</h1>
  <table>{rows}</table>
</body></html>"""

r = requests.post(
    "https://shotmint.creasework.com/v1/pdf",
    headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
    json={"html": html, "format": "A4"},
    timeout=60,
)
r.raise_for_status()
with open("report.pdf", "wb") as f:
    f.write(r.content)
print("saved report.pdf")

Both scripts produce the same 4-page A4 PDF: no header on page 1, "ACME Corp · Quarterly report" at the top of pages 2-4, and "Page 1 of 4" to "Page 4 of 4" at the bottom right.

Margins: CSS wins

When the stylesheet sets @page { margin: … }, that margin is used and the API's margin parameter is ignored (we rendered the same page with margin at its 10mm default and at 0mm: identical files). Leave the margin to CSS when you use margin boxes, and make it big enough for the text: 20-25mm top and bottom for 9pt text. Without a CSS margin, the API's margin applies to all four sides.

If you can't change the page: Puppeteer templates that work

When you print someone else's page (a URL, not your own HTML), you can't add @page rules, so you need the templates. This version fixes all three problems above. The classes pageNumber, totalPages, title, url and date are filled in by Chromium.

// puppeteer-footer.mjs: headerTemplate/footerTemplate that actually show up
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://en.wikipedia.org/wiki/PDF', { waitUntil: 'networkidle2' });

await page.pdf({
  path: 'page.pdf',
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  // 1. Templates ignore the page's CSS: set font-size, width and padding inline.
  headerTemplate: `<div style="font-size:9px; width:100%; padding:0 15mm; color:#666;">
      <span class="title"></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>`,
  // 2. They are drawn inside the page margin, so leave room for them.
  margin: { top: '25mm', bottom: '25mm', left: '15mm', right: '15mm' },
});
await browser.close();

On Ubuntu 23.10+ and in most containers, launch with { args: ['--no-sandbox'] } or Chrome won't start. Images in a template must be inline (a data: URL): the template can't load files from the page.

Same templates, without running Chrome: the Shotmint API

POST /v1/pdf takes the same templates as header_template and footer_template, for a url or for your own html. It sends the empty side as a blank span for you, so Chromium's default date and title line never shows up. For a plain centred "1 / 3" footer, send "page_numbers": true instead.

curl -sS --fail-with-body -X POST https://shotmint.creasework.com/v1/pdf \
  -H "X-API-Key: $SHOTMINT_KEY" -H "Content-Type: application/json" \
  -o wikipedia-pdf.pdf --data @- <<'JSON'
{
  "url": "https://en.wikipedia.org/wiki/PDF",
  "format": "A4",
  "margin": "20mm",
  "header_template": "<div style='font-size:8px; width:100%; padding:0 20mm; color:#666'><span class='title'></span></div>",
  "footer_template": "<div style='font-size:8px; width:100%; padding:0 20mm; color:#666; text-align:right'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
}
JSON

The same 28-page Wikipedia article comes back with the title at the top of every page and "Page N of 28" at the bottom right. The rules from the Puppeteer section still apply: inline styles, a margin big enough for the text, no external images. If your HTML already uses @page margin boxes, you don't need either option.

What we tested

We ran report.mjs (Node 22) and report.py (requests 2.32) against the live API: both returned a 4-page A4 PDF with the header missing on page 1 and correct "Page N of 4" footers, read back with pdftotext. The API curl above ran against the live API (28 pages, "Page 3 of 28" read back with pdftotext). The Puppeteer script ran on Puppeteer 25.14 with Chrome for Testing and printed a 28-page Wikipedia article with a 7.9 pt title header and "Page N of 28" footer on every page. The failure measurements come from Chromium 156 via Playwright, whose page.pdf() takes the same options.

Related: HTML to PDF invoices, PDFs with charts, HTML to PDF in Node.js, in Python. To number the pages of a PDF you already have, use the free add page numbers to PDF tool.

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 PDFs, credits never expire and there's no subscription.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons