·5 min read

How to add headers, footers, and page numbers to an HTML-to-PDF

Headers, footers, and page numbers in a Chromium-rendered PDF do not come from your page's CSS — Chromium's print pipeline ignores the CSS Paged Media @top-center/@bottom-center boxes. Instead you supply separate headerTemplate and footerTemplate HTML snippets that Chromium renders into the page margins, using built-in pageNumber and totalPages classes it injects for you. Docweave's /api/v1/pdf endpoint exposes this as options alongside your HTML/URL source, so you don't touch Puppeteer or Playwright directly.

Why your @page CSS header isn't showing up

If you've tried something like @page { @top-center { content: "Confidential" } } and rendered it through a Chromium-based HTML-to-PDF API, you've probably noticed it silently does nothing. That's not a bug in your CSS — it's a real gap in Chromium's implementation. Chromium's @page support covers size and margin, but it never implemented the CSS3 Paged Media margin boxes (@top-left, @top-center, @bottom-right, etc.) that tools like Prince or WeasyPrint support. This trips people up constantly because the spec looks like it should work, and other PDF engines (built on different rendering cores) do support it.

This matters for anyone comparing PDF tools: it's one of the real, non-marketing differences between a headless Chromium PDF engine and a dedicated print-formatter engine. Chromium wins on CSS/JS fidelity for laying out the page body — it just needs a different mechanism for running headers and footers.

The mechanism Chromium actually uses

Chromium's print-to-PDF path (what Playwright and Puppeteer call under the hood, and what Docweave's renderer uses) takes two separate HTML strings — a header template and a footer template — and renders them into fixed-height regions at the top and bottom of every page, independent of your main content. Turn it on with displayHeaderFooter, then supply headerTemplate and footerTemplate. Chromium recognizes a handful of special CSS classes inside those templates and fills them in automatically: date, title, url, pageNumber, and totalPages.

curl https://docweave.dev/api/v1/pdf \
  -H "Authorization: Bearer dw_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -d '{
    "source": { "html": "<h1>Q2 Report</h1><p>...</p>" },
    "options": {
      "format": "A4",
      "margin": { "top": "80px", "bottom": "60px", "left": "40px", "right": "40px" },
      "displayHeaderFooter": true,
      "headerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;color:#888;\">Docweave Quarterly Report</div>",
      "footerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;color:#888;\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
    }
  }' \
  --output report.pdf

The exact shape of the options object is versioned in the API reference — treat the field names above as the pattern (they follow the same convention as Playwright's page.pdf()), and check the docs before shipping if you're unsure of a specific key.

Same thing from Node

const res = await fetch("https://docweave.dev/api/v1/pdf", {
  method: "POST",
  headers: {
    Authorization: "Bearer dw_live_your_key",
    "Content-Type": "application/json",
    Accept: "application/pdf",
  },
  body: JSON.stringify({
    source: { html: "<h1>Q2 Report</h1><p>...</p>" },
    options: {
      displayHeaderFooter: true,
      headerTemplate: `<div style="font-size:9px;width:100%;text-align:center;color:#888;">Docweave Quarterly Report</div>`,
      footerTemplate: `<div style="font-size:9px;width:100%;text-align:center;color:#888;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
      margin: { top: "80px", bottom: "60px" },
    },
    idempotencyKey: "report-2026-q2-acme",
  }),
});

const buf = Buffer.from(await res.arrayBuffer());
require("fs").writeFileSync("report.pdf", buf);

For a fuller Node walkthrough — auth, error handling, retries — see Generate a PDF in Node.js.

Building a template that actually looks right

A few things catch people off guard the first time:

  • Header/footer templates render at a tiny default font size and don't inherit styles from your main document — they're a separate rendering context. Set font-size, color, and layout inline (or in a <style> tag inside the template string), not via your page's stylesheet.
  • Web fonts (@font-face, Google Fonts links) frequently fail to load in this context because the template renders before/without the full page's network stack settling. Stick to system fonts (Arial, Helvetica, sans-serif) for headers and footers.
  • width: 100% in the template refers to the printable width between your left/right margins, not the full page width — that's usually what you want for a centered footer.
  • The template's own height doesn't auto-reserve space in the body — you must set margin.top/margin.bottom large enough to clear whatever height your header/footer content needs, or the main content will overlap it.

Page numbers only, no visual header

If you just want "Page X of Y" and nothing else, skip the header entirely and keep the footer minimal:

<div style="font-size:9px; width:100%; text-align:center; color:#999;">
  <span class="pageNumber"></span> / <span class="totalPages"></span>
</div>

This is the most common request for invoices, quotes, and multi-page reports — a plain page counter is often all that's needed, and it avoids the font-loading headaches above entirely.

Via the MCP server

If you're generating documents from an agent using npx @docweave/mcp, the same options pass through the generate_pdf tool call:

await generate_pdf({
  source: { html: "<h1>Invoice #1042</h1>..." },
  outputPath: "./invoice-1042.pdf",
  options: {
    displayHeaderFooter: true,
    footerTemplate: `<div style="font-size:8px;width:100%;text-align:center;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
    margin: { bottom: "50px" },
  },
});

What you can't do (and the honest workarounds)

Chromium's print pipeline has real limits here — worth knowing before you design around them:

  • No different header/footer on page 1 vs. later pages natively. The common workaround is to render page 1 and the remaining pages as two separate PDF calls and merge them, or accept one header/footer design that's acceptable on every page.
  • No access to your main document's CSS variables, fonts, or classes inside the template — it's an isolated rendering context, so duplicate any styling you need.
  • totalPages is only known after Chromium lays out the whole document, which is why it's computed internally rather than something you pass in — you don't need a two-pass render yourself; Chromium already does that work before emitting the footer.

Print CSS for the body content itself

Once headers/footers are sorted, the other half of a print-quality PDF is making sure your body content breaks across pages sensibly. A few CSS rules pull real weight here:

@page {
  size: A4;
  margin: 0; /* let the API's margin option own spacing instead */
}

table, figure, .card {
  break-inside: avoid;
}

h1, h2, h3 {
  break-after: avoid;
}

p {
  orphans: 3;
  widows: 3;
}

Set page size in one place — either in this CSS or in the API's format/width/height option, not both, since conflicting values are a common source of unexpected blank pages. If you're new to the underlying model, the programmatic PDF generation glossary entry covers how the render pipeline turns HTML + CSS into a fixed-size document in the first place.

Wrapping up

Running headers, footers, and page numbers are a Chromium-specific mechanism, not a CSS one — headerTemplate/footerTemplate plus the pageNumber/totalPages classes, with margins sized to match. Once that clicks, invoices, contracts, and reports with a consistent "Page X of Y" footer are a five-minute change, not a fight with the print spec.

FAQ

Can I use CSS `@page { @top-center { content: ... } }` for a header instead?

No — Chromium's print engine doesn't implement the CSS3 Paged Media margin boxes (@top-left, @top-center, @bottom-right, etc.). It silently ignores them. You need the headerTemplate/footerTemplate options instead, which render as separate HTML into the page margins.

How do I show "Page 2 of 14"?

Put <span class="pageNumber"></span> and <span class="totalPages"></span> inside your footerTemplate HTML. Chromium computes and injects both values automatically after laying out the full document — you don't calculate total pages yourself.

Why does my header/footer font look different from the rest of the PDF?

Header and footer templates render in an isolated context that doesn't inherit your document's stylesheet or loaded web fonts. Set font styles inline in the template string, and prefer system fonts since custom @font-face fonts often fail to load in time.

Can the first page have a different header than the rest?

Not natively in a single render call. The practical workaround is two separate PDF generations — one for page 1 with its own header/footer, one for the remaining pages — merged afterward, or simply designing one header/footer that's acceptable on every page.

Related

Generate your first PDF in minutes.

Get an API key