Short answer: Puppeteer’s page.pdf() prints your HTML, but it does not build a visible, numbered table of contents (TOC). Create the TOC in your Node.js HTML pipeline, measure where each heading lands after print layout settles, convert those positions to page numbers, then render the PDF again. A second or convergence pass is necessary because the TOC itself can change pagination.
What Puppeteer can—and cannot—do
page.pdf() uses Chromium’s print CSS media type to generate a PDF. It does not expose a documented API that discovers headings and inserts a visible TOC with their printed page numbers. Puppeteer’s pageNumber and totalPages counters work in header and footer templates; they do not provide a per-heading page lookup. The outline PDF option is experimental and creates a document outline (bookmarks), which is separate from a page printed in your document.
The reliable design is therefore:
- Keep an ordered, structured list of sections.
- Give every emitted heading a deterministic, escaped
id. - Render a TOC placeholder with links before the main content.
- Wait for data, fonts and images before measuring heading positions.
- Render again with the measured page numbers, repeating if pagination changes.
Build a two-pass TOC in Node.js
1. Define sections and safely escape HTML
Do not concatenate untrusted titles or IDs directly into markup. This example keeps section metadata in one place and escapes text before insertion.
const puppeteer = require('puppeteer');
const sections = [
{ id: 'overview', title: 'Overview', level: 1 },
{ id: 'installation', title: 'Installation', level: 1 },
{ id: 'configuration', title: 'Configuration', level: 1 },
{ id: 'troubleshooting', title: 'Troubleshooting', level: 1 }
];
function esc(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
Use a slugging function when IDs are generated from user-authored headings. If duplicate headings are possible, append a counter so every ID remains unique.
#1 Best Overall
2. Render headings and the TOC placeholder
The renderer below accepts either unknown pages (null) or measured page numbers. Links remain useful even before page numbers are known.
function renderDocument(pageNumbers = new Map()) {
const tocRows = sections.map(section => {
const page = pageNumbers.get(section.id);
return `<li class="toc-level-${section.level}">
<a href="#${esc(section.id)}">${esc(section.title)}</a>
<span class="toc-page">${page == null ? '' : page}</span>
</li>`;
}).join('');
const headingHtml = sections.map(section =>
`<h${section.level} id="${esc(section.id)}">${esc(section.title)}</h${section.level}>
<p>Content for ${esc(section.title)}. Replace this text with your generated section.</p>`
).join('');
return `<!doctype html>
<html><head><meta charset="utf-8">
<style>
@page { size: A4; margin: 22mm 18mm 20mm; }
* { box-sizing: border-box; }
body { font: 11pt/1.45 Arial, sans-serif; margin: 0; }
h1, h2, h3 { break-after: avoid; }
#toc { break-after: page; }
#toc ul { list-style: none; padding: 0; }
#toc li { display: flex; gap: .5em; margin: .35em 0; }
#toc a { color: #000; text-decoration: none; flex: 1; }
.toc-page { min-width: 2em; text-align: right; }
.avoid-break { break-inside: avoid; }
</style></head><body>
<nav id="toc" aria-label="Table of contents">
<h1>Contents</h1><ul>${tocRows}</ul>
</nav>
<main>${headingHtml}</main>
</body></html>`;
}
3. Wait for the layout to settle
Navigation completion is not enough when your page loads web fonts, images or application data. Give images intrinsic dimensions, wait for document.fonts.ready, and use an explicit application-ready signal when your own code fetches content.
async function loadAndSettle(page, html) {
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
await Promise.all([...document.images].map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}));
});
}
By default, PDF generation uses print styles. Keep that behavior unless your design intentionally depends on screen CSS; in that case call await page.emulateMediaType('screen') before measuring and printing, and use the same setting in every pass.
4. Convert heading positions to printed pages
For a simple fixed-size layout, obtain each heading’s document-space top position and divide by the printable page height. The calculation must use the same paper size, margins and scale as page.pdf(). With A4, the sheet is 297 mm high; subtract top and bottom margins and convert millimetres to CSS pixels at 96 dpi. A production implementation should keep these values in one configuration object rather than duplicating constants.
Rank #2
const MM_TO_PX = 96 / 25.4;
const paperHeightPx = 297 * MM_TO_PX;
const marginTopPx = 22 * MM_TO_PX;
const marginBottomPx = 20 * MM_TO_PX;
const printableHeightPx = paperHeightPx - marginTopPx - marginBottomPx;
function pageForTop(top) {
return Math.max(1, Math.floor((top - marginTopPx) / printableHeightPx) + 1);
}
async function measureHeadings(page) {
return page.evaluate(() => [...document.querySelectorAll('h1[id],h2[id],h3[id]')]
.map(el => ({ id: el.id, top: el.getBoundingClientRect().top + window.scrollY })));
}
This estimate is valid only when the final PDF uses the same dimensions, margins, print CSS and scale. Complex cases—running headers, varying page boxes, CSS page counters, forced breaks, transforms or a renderer whose pagination differs from the browser viewport—should be verified by inspecting the produced PDF with a PDF-layout/parser step. Treat browser geometry as an estimate until validation confirms it.
5. Render, measure and converge
async function createPdf(outputPath) {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
const firstHtml = renderDocument();
await loadAndSettle(page, firstHtml);
let numbers = new Map();
for (let pass = 0; pass < 5; pass++) {
const measured = await measureHeadings(page);
const next = new Map(measured.map(item => [item.id, pageForTop(item.top)]));
const unchanged = [...next].every(([id, value]) => numbers.get(id) === value);
numbers = next;
if (unchanged && pass > 0) break;
await loadAndSettle(page, renderDocument(numbers));
}
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
footerTemplate: '<span class="pageNumber"></span> / <span class="totalPages"></span>',
margin: { top: '22mm', bottom: '20mm', left: '18mm', right: '18mm' }
});
} finally {
await browser.close();
}
}
createPdf('output.pdf').catch(err => { console.error(err); process.exitCode = 1; });
The loop stops when two successive measurements assign the same page to every heading, or after five passes. Choose a maximum appropriate for your document and log a warning if it is reached. A TOC that grows from one to two digits can move a heading; convergence is why measuring only once is unsafe.
Links, numbering and print-layout details
Use deterministic anchors
Use lowercase, stable IDs and never reuse an ID. A link such as <a href="#configuration"> gives readers navigation inside supporting PDF viewers, while the number is ordinary text that remains visible when links are disabled.
Control page breaks deliberately
Apply break-before: page to chapters that must start on a new page and break-inside: avoid to short heading-and-content groups. Avoid putting large unsplittable blocks around headings: a late image or table can push the heading to the next page after measurement.
Rank #3
Separate visible TOC from bookmarks
Where your deployed Puppeteer/Chromium version supports it, outline: true requests an experimental document outline. Test it against that exact version. It does not fill the visible TOC and should not replace measured page numbers.
Common failures and fixes
Every heading reports page 1
You likely measured getBoundingClientRect().top without adding window.scrollY, or measured a scrolled element. Measure document coordinates and ensure the page has the complete HTML before querying.
Numbers are consistently off by one
Check whether your formula includes the top margin, whether the PDF uses a different paper format, and whether header/footer margins differ from your CSS @page rule. Keep PDF options and the conversion constants together.
Numbers change on every pass
Fonts or images are still loading, content is nondeterministic, or the TOC is changing line wraps. Wait for fonts and image completion, provide image dimensions, freeze dynamic data, and use a convergence limit with a diagnostic warning.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Rank #4
TOC links work but printed pages do not
That is expected when the page number is blank or stale. Regenerate after measurement and verify the final PDF, not only the intermediate HTML.
External assets fail in headless Chromium
Use absolute URLs or embed assets, wait for network idle, and handle image errors explicitly. A failed image can alter layout after you measured it.
Header and footer overlap content
When displayHeaderFooter is enabled, reserve space with PDF margins and keep templates small. Re-run measurement with those exact options enabled.
Performance, accuracy and operational trade-offs
| Approach | Page-number accuracy | Complexity | Navigation | Performance |
|---|---|---|---|---|
| Single render with guessed numbers | Low when content changes | Low | Links only | Fastest |
| Two-pass measurement | Good for stable print layouts | Moderate | Visible links and numbers | About an extra render |
| Convergence loop | Best when the TOC changes pagination | Highest | Visible links and numbers | Several renders in the worst case |
| Experimental outline | Not a visible page-number TOC | Low, version-dependent | PDF bookmarks | Small additional work |
For repeatable builds, pin your Chromium/Puppeteer versions, use deterministic input data, validate a short and a long document, and inspect page assignments after CSS changes. Do not publish unsupported performance or page-count guarantees: pagination depends on fonts, assets, margins, breaks and content.
Free tools Windows power users keep installed
One-click scans. No signup required.
Or skip the browser setup
If you need screenshots or PDFs of web pages rather than a locally generated Puppeteer document, ScreenshotNeo provides a single HTTP endpoint. It accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; bot checks/CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server supports AI-agent tools including take_screenshot, get_page_info and capture_pdf.
For a PDF capture, use the API documentation at https://screenshotneo.com/docs/. The supplied request returns a clean image; configure PDF options such as paper size, margins, landscape mode and page ranges when using the PDF endpoint.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Every feature is available on every plan. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to try it.
FAQ
Can Puppeteer automatically number a visible TOC?
Not through a documented automatic TOC builder. Generate the markup and numbers in your own pipeline.
Are footer page counters enough for a TOC?
No. pageNumber and totalPages are running counters in header/footer templates, not heading-location data.
Should I use outline: true instead?
Use it only as optional PDF bookmarks and test the experimental behavior in your deployed version; it does not replace a visible TOC.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

