How-to9 min read

How to Add Page Numbers, Headers and Footers to an HTML-to-PDF

Add "Page X of Y", logos and dates to HTML-to-PDF output with Chromium header and footer templates, plus fixes for tiny or invisible footers.

By · Published

Short answer

To add page numbers to a PDF rendered from HTML with Chromium, pass a footer template containing <span class="pageNumber"> and <span class="totalPages">, enable header/footer display, give the template inline styles with an explicit font-size, and set a bottom margin large enough to hold it. Chromium fills in the numbers on every page.

Try discussing a twelve-page report on a call when it has no page numbers ("the table near the end, no, the one after that"). Adding them sounds like a one-line job, and in Chromium it nearly is, once you know one thing: headers and footers are rendered as separate little documents that never see your stylesheet. Almost every bug in this area comes from not knowing that.

Below is the approach that holds up in every Chromium-based renderer, from Puppeteer and Browsershot to htmltopdfapi.co, the HTML to PDF API we run: header and footer templates. We also cover the CSS-only routes, where Chrome 131's new margin-box support fits in, and the three mistakes that leave footers tiny or missing.

How do Chromium header and footer templates work?

Chromium's print-to-PDF function accepts two small HTML documents in addition to your page: a header template and a footer template. They are rendered separately from your main content, inside the top and bottom page margins, and repeated on every page. Puppeteer exposes these as headerTemplate and footerTemplate; on htmltopdfapi.co the equivalent request fields are header_html, footer_html and the switch show_header_footer.

Inside a template, Chromium looks for elements with specific class names and injects values into them at print time:

ClassValue injectedTypical use
pageNumberCurrent page number"Page 3"
totalPagesTotal number of pages in the PDF"of 12"
datePrint date, formatted by the browserGenerated-on stamp
titleThe document's <title>Running document title
urlThe document locationSource link on web captures

Templates are walled off from your page. Your stylesheets, your JavaScript and any external image URLs are all out of reach, so whatever a template needs has to live in the template string. Keep that in mind and the troubleshooting section below will make sense. The Puppeteer PDFOptions reference documents the same behavior.

How do I show "Page X of Y"?

You need a pageNumber span and a totalPages span in the footer template, templates switched on, and a bottom margin for the footer to sit in. As a complete request:

curl — Page X of Y footerbash
curl -X POST https://platform.htmltopdfapi.co/api/v1/pdf/generate \
  -H "Authorization: Bearer $HTML_TO_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  --output report.pdf \
  -d '{
    "url": "https://example.com/report",
    "paper_size": "a4",
    "margin_top": 15,
    "margin_bottom": 20,
    "show_header_footer": true,
    "header_html": "<div></div>",
    "footer_html": "<div style=\"font-size:9px;width:100%;text-align:center;color:#555\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
  }'

The empty header <div> is deliberate. With templates switched on, Chromium falls back to its default header (date and title) whenever you leave the header out, and an empty element is how you suppress it. The width:100% is there because the template container does not stretch its children, and text-align:center has nothing to center in a box that is only as wide as its text.

How do I add headers and footers with the PHP SDK?

The official PHP SDK (composer require htmltopdfapi/php-sdk) has ->header(), ->footer() and ->margins() methods. In Laravel, the facade is auto-discovered:

Laravel — header, footer and marginsphp
use HtmlToPdfApi\Laravel\Facades\HtmlToPdf;

$header = '<div style="font-size:9px;width:100%;padding:0 15mm;display:flex;justify-content:space-between;color:#666">'
        . '<span>Acme Ltd — Quarterly Report</span><span class="date"></span>'
        . '</div>';

$footer = '<div style="font-size:9px;width:100%;text-align:center;color:#666">'
        . 'Page <span class="pageNumber"></span> of <span class="totalPages"></span>'
        . '</div>';

return HtmlToPdf::html(view('reports.quarterly', $data)->render())
    ->paperSize('a4')
    ->margins(top: 25, bottom: 20)
    ->header($header)
    ->footer($footer)
    ->filename('q3-report.pdf')
    ->generate();

The header's horizontal padding is easy to miss. Templates span the full paper width, ignoring your side margins, so without padding:0 15mm the company name sits hard against the paper edge while the body text starts 15 mm in. For queues, Storage and testing around this, see generating PDFs in Laravel.

How do I add a footer from Node.js?

Node has no official SDK, and it does not need one here. The built-in fetch in Node 18+ is enough:

Node.js — fetch with a footer templatejavascript
import { writeFile } from 'node:fs/promises'

const footer = `
  <div style="font-size:9px;width:100%;text-align:center;color:#555">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span>
  </div>`

const res = await fetch('https://platform.htmltopdfapi.co/api/v1/pdf/generate', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HTML_TO_PDF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: '<h1>Report</h1><p>…</p>',
    paper_size: 'a4',
    margin_top: 15,
    margin_bottom: 20,
    show_header_footer: true,
    header_html: '<div></div>',
    footer_html: footer,
  }),
})

if (!res.ok) throw new Error(`PDF failed: ${res.status} ${await res.text()}`)
await writeFile('report.pdf', Buffer.from(await res.arrayBuffer()))

Retries, Express and Fastify routes, and streaming are covered in the Node.js guide.

Why is my footer tiny or invisible?

It is the first header bug nearly everyone hits, and the cause is almost always one of these three:

  1. No explicit font-size. Templates do not inherit your page styles, and Chromium's default text size inside them is so small that "Page 1 of 3" reads as a gray smudge. Set font-size inline on the outer element; 9px or 10px matches most body text.
  2. No margin to render into. Headers and footers are drawn inside the page margins. If margin_bottom is 0 (or very small), the footer has no space and gets clipped or disappears. Start with 15–20 mm for a single-line footer and increase it for multi-line ones.
  3. Styles in a stylesheet or class. A <link> tag, a Tailwind class or a rule in your main CSS has no effect inside the template. Use inline style="" attributes. A <style> block inside the template string itself does work in Chromium, but inline styles are the safest option.

If none of those is the culprit, confirm show_header_footer is actually true in the request you sent (log the payload), set color explicitly rather than trusting the default, and add -webkit-print-color-adjust: exact to any template element with a background.

How do I put a logo in the header?

Because templates cannot load external resources, an <img src="https://…/logo.png"> will render as a blank space. Embed the image as a base64 data URI instead. Keep the file small, a few kilobytes of optimized PNG or SVG, because the template travels with every request and base64 inflates the file by about a third against the 50,000-character limit on header_html.

PHP — logo as a data URIphp
$logo = base64_encode(file_get_contents(public_path('img/logo.png')));

$header = '<div style="width:100%;padding:0 15mm;display:flex;align-items:center;font-size:9px">'
        . '<img src="data:image/png;base64,' . $logo . '" style="height:24px">'
        . '<span style="margin-left:auto">Invoice <span class="pageNumber"></span>/<span class="totalPages"></span></span>'
        . '</div>';

Give the image an explicit height. A 400-pixel-tall source logo rendered at its intrinsic size will overflow a 25 mm margin and get clipped at the top of the page.

Can I hide the header on the first page?

Not with Chromium templates. They are stamped identically on every page, with no per-page condition and no first-page variant. @page :first can change the first page's margin, but it cannot switch a template off. Two workarounds do the job:

  • Generate the cover separately. Render the cover page as its own PDF without templates, render the body with templates, then merge the two files with a PDF tool such as qpdf, pdf-lib or a PHP merge library. Page numbering in the body will start at 1, which is often what you want after a cover anyway.
  • Use CSS fixed elements instead of templates. Leave show_header_footer off and build the header in your own HTML, as described in the next section. A full-bleed cover with a solid background placed above the fixed header (higher z-index) will hide it on page one.

What CSS-only alternatives exist?

In Chromium, an element with position: fixed repeats on every printed page. That gives you a header or footer that uses your normal stylesheet, web fonts and external images. Pad the top and bottom of the body so content does not run underneath it. What you cannot do this way is show "Page X of Y", because a fixed element has no access to page numbers.

The CSS Paged Media spec has a cleaner answer: margin boxes such as @top-center and @bottom-right inside @page, filled with content: counter(page) and counter(pages). Prince and WeasyPrint have supported them for years. Chrome added margin boxes in version 131 (late 2024), so tutorials that say "Chromium ignores @bottom-center" were right until then. Whether they work for you depends on the Chromium build your renderer ships, which is why we still default to header and footer templates; they behave the same in every version. MDN's @page reference has the spec details and the compatibility table.

In every Chromium version, @page reliably handles size and margins, and that pairs well with templates. Where pages split is its own topic, covered in CSS page breaks for PDF.

TechniquePage numbersUses your CSS/fontsWorks in Chromium
Header/footer templatesYes (pageNumber, totalPages)No, inline styles onlyYes
position: fixed elementsNoYesYes
@page margin boxes with counter(page)YesYesChrome 131+ only

Which documents need headers and footers?

Any document a reader flips through or cites by page. Reports need page numbers for the table of contents to mean anything. Contracts should carry the party names and "Page X of Y" on every page so a missing page is obvious. Invoices with three pages of line items need at least a page count. If rendering documents over HTTP is new to you, read what an HTML to PDF API is first; every example on this page runs on the free plan listed on the pricing page.

Frequently asked questions

How do I add page numbers to a PDF generated from HTML in Chromium?

Use a footer template containing <span class="pageNumber"></span> and <span class="totalPages"></span>, enable header/footer display, and set a bottom margin of around 15–20 mm so the footer has room.

Why is my Puppeteer or Chromium PDF footer so small?

Header and footer templates do not inherit your page styles and default to a very small font size. Set font-size inline on the template, for example style="font-size:9px".

Why does my PDF footer not show at all?

Most often the bottom margin is zero or too small, so the footer has no space to render, or header/footer display is not enabled. Increase margin_bottom and set show_header_footer to true.

Can I use an external image in a Chromium PDF header?

No. Header and footer templates cannot load external resources. Embed the image as a base64 data URI and give it an explicit height.

Does Chromium support CSS counter(page) or @bottom-center?

Only recent versions. Chrome added CSS Paged Media margin boxes such as @bottom-center, with counter(page), in version 131; older Chromium builds ignore them. Header and footer templates work in every version, so they are the safer way to add page numbers.

Can I hide the header on the first page of a PDF?

Not with Chromium templates, which apply to every page. Render the cover as a separate PDF and merge it, or build the header with CSS fixed elements that a full-page cover can cover.

Written by

Written by the engineers who build and run htmltopdfapi.co and maintain the official PHP/Laravel SDK. We ship PDF-heavy features — invoices, reports and certificates — every day, and every guide here comes from that work.