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

Don't want to run Chrome yourself? We're building a small HTML-to-PDF API with these fixes on by default: repeating headers, whole rows, page numbers and fonts that match.

We'll email you when the API is ready to try. Privacy

More guides