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.
| Class | What it prints |
|---|---|
date | The date the PDF was printed |
title | The document title, from the page <title> |
url | The page address (with setContent, usually about:blank) |
pageNumber | The number of the current page |
totalPages | How 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
- Turn the bands on. Pass
displayHeaderFooter: truetopage.pdf(). Without it, Chrome ignores both templates. - Write every style inline. Put
font-size,font-family, colour and padding instyleattributes or a<style>tag inside the template string itself. The page stylesheet does not reach it. - Set a readable font size. Give the outer element an explicit size such as 9px or 10px. The default is very small.
- Make room with margins. Set
margin.topandmargin.bottomlarger than the bands, for example 28mm and 20mm. Too small, and the band is clipped or covers your content. - 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. - 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:
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:
@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:
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.