The problem: HTML has no pages to count
Your client wants a twelve-page service agreement with "Page 4 of 12" at the bottom of each sheet. On screen the document is one long scroll. There is no page four yet, so there is nothing in the HTML you can number.
Most people first try a footer <div> with position: fixed. It does appear on every printed page in many engines. But it cannot count, so it shows the same text everywhere. Others add numbers by hand, which breaks the moment one clause grows by a line and pushes everything down.
Page numbers matter more than they seem. Lawyers cite them, auditors check them, and readers use the total to know nothing is missing. A report without them looks unfinished, even when everything else is right.
Why page numbers need a paged media engine
A page number only exists after the content has been split into pages. CSS has a standard for this, called Paged Media. It gives each page a set of margin boxes, such as @top-left, @bottom-center and @bottom-right, and two counters: page for the current page and pages for the total.
Support for these rules arrived late in browsers, and it is still uneven. That is why tools built on a browser often add their own header and footer system instead. A paged media engine such as Paged.js fills the gap: it lays the pages out in JavaScript first, then fills each margin box with the right numbers.
- Margin boxes live in the page margin. If the bottom margin is 10mm, a footer with two lines of text has nowhere to go. Size the margin for the content you put in it.
- The total is only known at the end.
counter(pages)needs the whole document laid out before the first footer can be filled. A paged media engine handles this for you. - Features differ by engine. Running headers taken from your content (
string-set) and page references (target-counter) need full paged media support, not just a print stylesheet.
Add Page X of Y, step by step
- Reserve room in the margin. Give the
@pagerule a bottom margin large enough for the footer, for example 25mm, so the number does not crowd the body text. - Place the counters in a margin box. Inside
@page, add@bottom-rightwithcontent: "Page " counter(page) " of " counter(pages). Style the text right there, with a font size and colour. - Hide the number on the cover. Add an
@page :firstrule that sets the same margin box tocontent: none. The cover stays clean, and page two still reads "Page 2". - Render in print mode. Send
"mode": "print"with raw HTML. Saved templates already render in print mode unless you change their settings. - Check the total. Read the
x-pagesresponse header and compare it with the last footer in the PDF. They should match.
This stylesheet numbers a service agreement. The page number sits on the right, a document reference sits on the left, and the cover has neither:
@page {
size: A4;
margin: 22mm 18mm 28mm;
@bottom-left {
content: "Agreement SA-2026-118";
font-size: 8pt;
color: #94a3b8;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 8pt;
color: #475569;
}
}
@page :first {
@bottom-left { content: none; }
@bottom-right { content: none; }
}
.cover { break-after: page; }Put the stylesheet in the css field and the markup in html. The request below is shortened, but every field in it is real:
{
"html": "<section class=\"cover\"><h1>Service Agreement</h1><p>Northwind Studio and Halden Logistics</p></section><h2>1. Scope</h2><p>The supplier provides monthly maintenance.</p><h2>2. Fees</h2><p>Fees are invoiced quarterly.</p>",
"css": "@page { margin: 22mm 18mm 28mm; @bottom-right { content: \"Page \" counter(page) \" of \" counter(pages); font-size: 8pt; } } @page :first { @bottom-right { content: none; } } .cover { break-after: page; }",
"mode": "print",
"format": "A4",
"filename": "service-agreement.pdf"
}curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer $CASTPDF_API_KEY" \
-H "Content-Type: application/json" \
-d @agreement-request.json \
--dump-header headers.txt \
--output service-agreement.pdf
grep -i "^x-pages" headers.txtThe cover counts as page one even though it shows no number. So the first footer you see reads "Page 2 of 2" here, and that is correct: the reader holds two sheets.
Running headers, contents pages and other variations
A running header with the current chapter
Long documents often show the chapter name at the top of each page. CSS can copy text from your content into the margin with string-set. You name a string on the element that holds the text, then print it with string() in a margin box. The value updates on each page where a new chapter starts.
h1.chapter {
string-set: chapter content(text);
break-before: page;
}
@page {
margin: 26mm 20mm 24mm;
@top-center {
content: string(chapter);
font-size: 8.5pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@bottom-center {
content: counter(page);
font-size: 9pt;
}
}Page numbers in a table of contents
A contents page needs the page where each chapter lands, which you cannot know before layout. The target-counter function looks it up for you. Link each entry to the chapter heading by its id, and the engine prints the page number after the link text.
<style>
.contents a { display: flex; color: inherit; text-decoration: none; }
.contents a::after {
content: target-counter(attr(href), page);
margin-left: auto;
}
</style>
<nav class="contents">
<a href="#leave">Annual leave</a>
<a href="#expenses">Travel and expenses</a>
<a href="#equipment">Equipment</a>
</nav>
<h1 class="chapter" id="leave">Annual leave</h1>
<h1 class="chapter" id="expenses">Travel and expenses</h1>
<h1 class="chapter" id="equipment">Equipment</h1>Fast mode and page numbers
CastPDF has two render modes. Fast mode uses the browser print engine directly and skips the pagination step. In fast mode, string-set and target-counter have no effect. Our page counter tests run in print mode, so send "mode": "print" whenever a document needs numbers, running headers or a contents page.
The Puppeteer footer template
If you run Chrome yourself through Puppeteer, you may use its footer template instead of CSS. It fills elements that have the classes pageNumber and totalPages:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent('<h1>Service Agreement</h1><p>Clause text.</p>');
await page.pdf({
path: 'agreement.pdf',
format: 'A4',
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate: '<div style="font-size: 9px; width: 100%; text-align: right; padding-right: 15mm;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: '15mm', bottom: '20mm' },
});
await browser.close();It works, but the footer lives outside your HTML and needs its own inline styles. It also cannot read chapter titles from the page. The Puppeteer header and footer guide covers its options and the usual surprises.
How CastPDF handles it
In print mode, CastPDF runs Paged.js inside Chromium before it prints. Paged.js supports the @page margin boxes, the page and pages counters, and named strings. Footers with Page X of Y, running headers with string-set and contents pages with target-counter are each covered by the renderer test suite.
When you set no margins, pages get 20mm on every side. That fits a single small line of footer text, but larger footers need more. Set margins in the request, in the template settings, or in your own @page rule, which wins over both.
Numbers count pages correctly only when pages break where you expect, so the page break guide is a good companion. For long tables, add a repeating table header. If a footer uses a brand font, read fonts in HTML to PDF first. The full rule list is in the print CSS reference.