Generate PDFs from HTML with Puppeteer
Create a two-page PDF from inline HTML, with print CSS, page breaks, margins, page numbers and practical font/header/footer troubleshooting.
Use page.pdf() after HTML preparation or validated navigation and meaningful document readiness. PDF generation uses print layout by default; screen screenshots and printed pages are different results.
Generate a PDF step by step
Run npm install puppeteer in your project. We will prepare a two-page invoice, wait for fonts, and print it with a page-number footer. The snippets use the page created by the complete script below; all HTML and footer markup stay inline.
1. Prepare the HTML and print CSS
The @page rule chooses A4 paper and margins. A page break after the invoice sends the terms to page two.
await page.setContent(`
<style>
@page {
size: A4;
margin: 20mm;
}
body {
font-family: sans-serif;
}
.invoice {
break-after: page;
}
</style>
<section class="invoice">
<h1>Invoice INV-001</h1>
<p>Example service: €120.00</p>
<strong>Total: €120.00</strong>
</section>
<section>
<h1>Terms</h1>
<p>
This synthetic invoice contains
no customer information.
</p>
</section>
`);
2. Wait for the fonts
Wait for the current document’s font-loading work before printing. Real pages may also need checks for images and application data.
await page.evaluate(() => document.fonts.ready);
3. Write the PDF with margins and page numbers
Let the CSS page size take priority, include backgrounds, and reserve room for the footer. The special pageNumber and totalPages classes fill in each page’s numbers. The complete script closes the browser in finally.
await page.pdf({
path: "invoice.pdf",
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: `
<div style="
font-size: 10px;
text-align: center;
width: 100%;
">
Page <span class="pageNumber"></span>
of <span class="totalPages"></span>
</div>
`,
margin: {
top: "20mm",
bottom: "20mm",
left: "20mm",
right: "20mm",
},
});
Complete script
Here are all the pieces together, including setup and cleanup. Save this as invoice.mjs and run node invoice.mjs.
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: true,
});
try {
const page = await browser.newPage();
await page.setContent(`
<style>
@page {
size: A4;
margin: 20mm;
}
body {
font-family: sans-serif;
}
.invoice {
break-after: page;
}
</style>
<section class="invoice">
<h1>Invoice INV-001</h1>
<p>Example service: €120.00</p>
<strong>Total: €120.00</strong>
</section>
<section>
<h1>Terms</h1>
<p>
This synthetic invoice contains
no customer information.
</p>
</section>
`);
await page.evaluate(() => document.fonts.ready);
await page.pdf({
path: "invoice.pdf",
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: `
<div style="
font-size: 10px;
text-align: center;
width: 100%;
">
Page <span class="pageNumber"></span>
of <span class="totalPages"></span>
</div>
`,
margin: {
top: "20mm",
bottom: "20mm",
left: "20mm",
right: "20mm",
},
});
} finally {
await browser.close();
}
The output is invoice.pdf in the current directory. The first section deliberately ends with a page break; the terms go on page two. Inspect the rendered pages for your real content: a valid PDF header or page count cannot prove visual correctness.
Page size, breaks and margins
preferCSSPageSize gives the CSS page size priority. If you want Puppeteer to control sizing instead, use format or supported width and height values. Avoid mixing competing sizing rules.
Use print CSS to remove unwanted navigation and keep important blocks together. Avoid a forced break after the final section that creates a blank trailing page. Leave enough margin for headers/footers and check long tables/descriptions, not just a small sample.
printBackground: true includes backgrounds. print-color-adjust can request faithful colors, but still inspect the output on the target runtime.
URL input versus inline HTML
A URL naturally provides a base for relative assets. With page.setContent(), use absolute asset URLs or an explicit trusted <base href="..."> if assets are relative. Do not assume it finishes asynchronous application data.
Inside the page lifecycle, check current images before printing:
await page.evaluate(async () => {
for (const image of document.images) {
await image.decode();
if (!image.naturalWidth) {
throw new Error(`Broken image: ${image.src}`);
}
}
});
For navigation, validate the response and required content as shown in navigation handling. Do not accept empty bytes or continue to a success artifact after a required failure.
Fonts and locale
page.pdf() waits for fonts by default, but images and application data may still be missing. A fallback font can change wrapping and page count. Check representative characters and the fonts installed in the environment where you generate PDFs.
Locale-sensitive content needs separate locale/timezone configuration. Applying a locale does not install fonts or translate document text.
Can footerTemplate hide the first-page footer?
There is no hideFooterOnFirstPage option. Templates have special page-number classes, but do not inherit the page’s styles or execute arbitrary template scripts. JavaScript inside a footer cannot implement per-page conditions.
If that requirement is essential, evaluate a different print design or a separately validated multi-pass/merge workflow. Merging can affect page numbers, links, accessibility and layout. This example uses the same footer on both pages; it is not a merge workaround.
Runtime and security limits
This example uses Puppeteer with Chrome. Inspect the generated PDF for missing content, incorrect page breaks and font problems. See PDF input errors and missing print stream if generation fails.
Never feed arbitrary URLs/HTML to a privileged browser that can reach internal services or secrets. Restrict destinations, subresources, redirects and output paths at appropriate boundaries. An isolated context is not an SSRF defense; keep the sandbox enabled.