Skip to content
CastPDF

Puppeteer PDF header and footer that actually show up

To add a header and footer in Puppeteer, call page.pdf() with displayHeaderFooter: true, an HTML headerTemplate and footerTemplate, and top and bottom margins big enough to hold them. Style the templates inline and embed images as base64. Or skip templates and use CSS @page rules with our HTML to PDF API.

  • Updated October 2026
  • 100 free PDFs a month

The problem: the header is tiny, unstyled or missing

You render a monthly account statement with Puppeteer. You pass a footer with your company name and a page count. The PDF comes back, and the footer text is so small you need to zoom in to read it. Your brand font is gone. The logo is a broken image icon, and on some pages the footer overlaps the last table row.

None of this is a bug in your code. Puppeteer hands the header and footer to Chrome, and Chrome draws them in a separate context from your page. Once you know the rules of that context, the fixes take a few minutes.

How Puppeteer headers and footers work

Puppeteer is a free, open source library that drives Chrome. Its page.pdf() method prints the current page to a PDF. When you set displayHeaderFooter: true, Chrome adds a header band and a footer band to every page. You describe each band with an HTML string: headerTemplate and footerTemplate.

Inside a template, Chrome fills elements that carry certain class names. You write an empty <span> with the class, and Chrome puts the value inside it when it prints each page.

Classes Chrome fills in a Puppeteer template
ClassWhat it prints
dateThe date the PDF was printed
titleThe document title, from the page <title>
urlThe page address (with setContent, usually about:blank)
pageNumberThe number of the current page
totalPagesHow many pages the PDF has

The bands sit inside the page margins. Your content fills the space between them. So the margins you pass to page.pdf() decide how much room each band gets.

Set up a header and footer, step by step

  1. Turn the bands on. Pass displayHeaderFooter: true to page.pdf(). Without it, Chrome ignores both templates.
  2. Write every style inline. Put font-size, font-family, colour and padding in style attributes or a <style> tag inside the template string itself. The page stylesheet does not reach it.
  3. Set a readable font size. Give the outer element an explicit size such as 9px or 10px. The default is very small.
  4. Make room with margins. Set margin.top and margin.bottom larger than the bands, for example 28mm and 20mm. Too small, and the band is clipped or covers your content.
  5. Embed images as base64. Read the logo file, encode it, and use a data: URL as the image source. Links to image files usually do not load inside templates.
  6. Fill the template you do not need. If you only want a footer, pass an empty element such as <span></span> as the header. Otherwise Chrome may print its default header with the date and title.

Here is the full script for the account statement. Notice that the logo and every style travel inside the template strings:

statement-pdf.mjs (Puppeteer)
import { readFileSync } from 'node:fs';
import puppeteer from 'puppeteer';

const logo = readFileSync('./logo.png').toString('base64');
const html = readFileSync('./statement.html', 'utf8');

const band = 'width: 100%; padding: 0 16mm; font-family: Arial, sans-serif; font-size: 9px; color: #334155;';

const headerTemplate =
  '<div style="' + band + ' display: flex; justify-content: space-between; align-items: center;">' +
  '<img src="data:image/png;base64,' + logo + '" style="height: 16px;">' +
  '<span class="title"></span>' +
  '</div>';

const footerTemplate =
  '<div style="' + band + ' display: flex; justify-content: space-between;">' +
  '<span>Printed <span class="date"></span></span>' +
  '<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>' +
  '</div>';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  await page.pdf({
    path: 'statement.pdf',
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
    margin: { top: '28mm', bottom: '20mm', left: '16mm', right: '16mm' },
  });
} finally {
  await browser.close();
}

The band string keeps the two templates consistent, and the horizontal padding lines the text up with the body margins. The title span shows whatever the <title> of statement.html says, so set it per statement.

The gotchas, one by one

Your page CSS does not apply

The templates are rendered on their own, apart from your document. Classes from your stylesheet do nothing there, and neither do fonts loaded by the page. Treat each template as a tiny, self-contained HTML file.

The text is almost unreadable

Without a font size, header text comes out very small. Always set one on the outer element. Sizes between 8px and 11px read well on A4 and Letter.

The band overlaps the content, or vanishes

The band is drawn inside the page margin. With the default margins there may be no room at all, and the band is cut off. Raise the top and bottom margin until the band and the body no longer touch.

Images and colours

Image links inside a template often fail to load, so a base64 data: URL is the reliable choice. Keep the image small, since it is repeated on every page. Background colours may be dropped too. Adding -webkit-print-color-adjust: exact to the coloured element usually brings them back.

Every page gets the same band

A template has no idea which section of the document a page belongs to. It cannot show a chapter title, and it has no switch to skip the cover. Teams that need this often render the cover as a separate PDF and merge the files, which adds a step to every document.

The CSS alternative: @page margin boxes

CSS Paged Media describes headers and footers in the stylesheet itself. You add margin boxes such as @top-left and @bottom-right to an @page rule. Because they live in your CSS, they use your fonts and your colours, and they can read text from the document.

Here is the same statement band in CSS. The title and print date come from elements in the HTML through string-set, which replaces the title and date classes. The cover rule removes both bands from page one:

statement-print.css
@page {
  size: A4;
  margin: 28mm 16mm 20mm;
  @top-left {
    content: "Halden Credit Union";
    font: 700 9pt "Noto Sans", sans-serif;
    color: #0f766e;
  }
  @top-right {
    content: string(statement-title);
    font: 9pt "Noto Sans", sans-serif;
  }
  @bottom-left {
    content: "Issued " string(issued-on);
    font: 8pt "Noto Sans", sans-serif;
    color: #64748b;
  }
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
    font: 8pt "Noto Sans", sans-serif;
  }
}
@page :first {
  @top-left { content: none; }
  @top-right { content: none; }
}
h1.statement-title { string-set: statement-title content(text); }
.issued-on { string-set: issued-on content(text); }

The HTML is a Liquid template, so the statement title and issue date are filled from your data. A few lines of Node.js send it with the stylesheet in print mode:

statement-castpdf.mjs (Node.js 20+)
import { readFileSync, writeFileSync } from 'node:fs';

const res = await fetch('https://api.castpdf.com/v1/pdf', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.CASTPDF_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: '<h1 class="statement-title">Statement for {{ account }}</h1>' +
      '<p class="issued-on">{{ issued | format_date: "long" }}</p>' +
      readFileSync('./statement-body.html', 'utf8'),
    css: readFileSync('./statement-print.css', 'utf8'),
    data: { account: 'ACC-20931', issued: '2026-10-01', transactions: [] },
    mode: 'print',
    format: 'A4',
  }),
  signal: AbortSignal.timeout(60000),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(error.code + ': ' + error.message);
}
writeFileSync('statement.pdf', Buffer.from(await res.arrayBuffer()));

There is no logo to encode and no inline style string to maintain. The trade-off is that string-set and margin boxes need a paged media engine, so the request asks for "mode": "print". The page numbers guide goes deeper into counters and contents pages.

How CastPDF handles it, and when Puppeteer is the better fit

CastPDF renders your HTML in Chromium too. In print mode, Paged.js paginates the document before printing, which adds margin boxes, page counters and named strings. Footers with Page X of Y, running headers from string-set and contents pages with target-counter are each covered by the renderer test suite.

You also stop running Chrome yourself. There is no browser to keep patched, no memory to watch, and no missing system fonts on a fresh server. The print CSS reference lists every rule the renderer adds, and the CastPDF vs Puppeteer comparison sets out the costs side by side.

Puppeteer remains a good choice in many cases. It is free, it gives you full control of the browser, and its templates handle a simple page count well. If you already run Chrome and only need "Page 2 of 5", the script above is enough. Brand fonts are the next common snag in either setup, so read fonts in HTML to PDF. For long tables, see the repeating table header guide and the page break guide.

FAQ

Common questions

Why is my Puppeteer footer text so small?

Header and footer templates start with a very small default font size. Set an explicit size such as 9px or 10px on the outer element of the template. Your page styles do not apply there.

Why does my Puppeteer header ignore my CSS?

Chrome renders the templates apart from your page, so your stylesheet and loaded fonts do not reach them. Write every style inline in the template string, or in a style tag inside it.

How do I add a logo to a Puppeteer PDF header?

Read the image file, encode it as base64, and use a data URL as the image source in headerTemplate. Image links often do not load inside templates. Keep the file small because it repeats on every page.

Why is my Puppeteer header not showing at all?

Check that displayHeaderFooter is true and that the top margin is larger than the header. With small margins the band has no room and gets clipped.

Which classes can I use in headerTemplate and footerTemplate?

Chrome fills date, title, url, pageNumber and totalPages. Put the class on an empty span and Chrome writes the value into it on each page.

Can I replace Puppeteer templates with CSS?

Yes. CSS @page margin boxes with counter(page), counter(pages) and string-set do the same job inside your stylesheet. They need a paged media engine, such as CastPDF print mode.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.