Puppeteer PDF tables: repeat the header on every page and stop rows splitting
Tested: Puppeteer 23 with Chrome 127 and Chrome 159, A4, page.pdf(), 2026-10-30
Short answer: put your header row in <thead> and add one line of CSS:
tr { break-inside: avoid; }
Chrome already repeats <thead> at the top of every printed page. What goes wrong by default is the rows: Chrome cuts a tall row in half, so its top half sits on one page and its bottom half on the next.
We checked this with a 70-row invoice table rendered by page.pdf() in Chrome 127 and Chrome 159:
| CSS | Header on every page | Rows cut in half |
|---|---|---|
| none | 7 of 7 pages | 4 |
tr { break-inside: avoid; } |
7 of 7 pages | 0 |
A complete example
import puppeteer from 'puppeteer';
const html = `<!doctype html>
<html><head><style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #999; padding: 4px; }
tr { break-inside: avoid; } /* never cut a row in half */
h2 { break-after: avoid; } /* keep a heading with the table under it */
</style></head><body>
<h2>Line items</h2>
<table>
<thead><tr><th>#</th><th>Description</th><th>Amount</th></tr></thead>
<tbody>
<!-- your rows -->
</tbody>
</table>
</body></html>`;
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
margin: { top: '15mm', bottom: '15mm', left: '12mm', right: '12mm' },
});
await browser.close();
Why your header isn't repeating
If the header shows only on page 1, the cause is almost always the markup. We tested each of these:
| Your table | Header repeats? |
|---|---|
Header row inside <thead> |
Yes |
Header row is the first row of <tbody> (no <thead>) |
No, page 1 only |
thead { display: block; } (from a CSS reset or "responsive table" CSS) |
No, page 1 only |
A "table" built from <div>s or CSS grid, which is how many React data-grid components render |
No, it isn't a table to Chrome |
Table inside a flex, grid, overflow: hidden or overflow-x: auto wrapper |
Yes |
position: sticky on the <th> cells |
Yes |
table { display: block; } |
Yes |
The fix for the first two is to move the header row into <thead>, and to remove whatever sets display on it. For div-based grids, render a plain <table> for the PDF. A print-only template is usually simpler than fighting the grid component.
Three more things that bite
1. <tfoot> repeats on every page too. If your grand total lives in <tfoot>, it prints at the bottom of every page, not just the last one. Put the total in the last row of <tbody>, or in a block after the table.
2. Don't put break-inside: avoid on the whole table. If the table is longer than one page, Chrome moves the entire table to the next page, which leaves a mostly empty page behind, and then splits the rows anyway. We saw exactly that: the table jumped from page 1 to page 2 and four rows were still cut. Put it on tr, not table.
3. A heading can get stranded at the bottom of a page. When a heading lands near the page end, Chrome can put it on one page and the table on the next. h2 { break-after: avoid; } fixes it. In our test, the heading was stranded at one of 21 page positions without the rule and at none with it.
Checklist
- Header row in
<thead>, with nodisplayoverride tr { break-inside: avoid; }h2, h3 { break-after: avoid; }for headings right before tables- Totals in the last body row, not
<tfoot>(unless you want them on every page) printBackground: trueif your rows use background colours (Puppeteer leaves backgrounds out by default)