Skip to content
CastPDF

Repeat table headers on every page of a PDF

To repeat a table header on every page of a PDF, put the header row in a <thead>, keep the table a real CSS table, and stop rows from splitting with break-inside: avoid. Then render in a print engine that repeats headers, as our HTML to PDF API does in print mode.

  • Updated October 2026
  • 100 free PDFs a month

The problem: page two has no header

You have an invoice, a statement or a report with a long table. Page one looks right. Then the table runs onto page two, and the column names are gone. Your reader sees a column of numbers and has to flip back to work out which one is the tax.

It is one of the most common complaints about HTML to PDF tools. Developers describe "ugly page breaks, no page numbers, no repeating header" in plugin reviews, and long invoices are where it shows up first. The good news: it has a small number of causes, and each one has a short fix.

Why the header disappears

Browsers repeat a table header when the header rows sit in an element with display: table-header-group. That is what a <thead> is by default. So the header goes missing when something breaks that chain.

  • The header row is in the body. Generated HTML often puts every row, the header too, inside <tbody>. The browser cannot know which row to repeat.
  • The table is no longer a table. A CSS framework or a responsive style sets display: block, flex or grid on the table or its <thead>. The element stops being a header group, so nothing repeats.
  • A script split the table. Libraries that paginate in JavaScript copy the content into page boxes. Many of them do not copy the header to the next box unless a plug-in does it.
  • The header is not a header at all. A styled <div> above the table looks like one on screen, but print engines have no reason to repeat it.

The fix, step by step

  1. Move the header row into a thead. Wrap the column names in <thead> and every data row in <tbody>. Use <th> cells for the names.
  2. Keep it a CSS table. Remove display: block, flex or grid from the <table>, <thead> and <tr> in your print styles. Use @media print if the screen version needs them.
  3. Stop rows from splitting. Add tr { break-inside: avoid; } so a row moves to the next page whole instead of being cut in half.
  4. Keep the totals together. Put the subtotal, tax and total in one block with break-inside: avoid, so the total never sits alone at the top of a page.
  5. Render in print mode. Send the request with "mode": "print", or use a saved template, which renders in print mode by default.

Here is the markup and CSS of a minimal invoice table that repeats its header. The {% for %} loop is Liquid: CastPDF fills it from the lines in your data.

invoice-table.html
<style>
  table { width: 100%; border-collapse: collapse; }
  th, td { padding: 6px 8px; border-bottom: 1px solid #e2e8f0; text-align: left; }
  tr { break-inside: avoid; }
  .totals { break-inside: avoid; margin-top: 16px; }
</style>
<table>
  <thead>
    <tr><th>Description</th><th>Qty</th><th>Amount</th></tr>
  </thead>
  <tbody>
    {% for line in lines %}
    <tr><td>{{ line.description }}</td><td>{{ line.qty }}</td><td>{{ line.amount | money: "USD" }}</td></tr>
    {% endfor %}
  </tbody>
</table>
<div class="totals">
  <p>Total: {{ total | money: "USD" }}</p>
</div>

Send it with 60 lines of data and print mode, and you get a three-page PDF with the header at the top of every page:

curl
curl https://api.castpdf.com/v1/pdf \
  -H "Authorization: Bearer $CASTPDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d @invoice-request.json \
  --output invoice.pdf
invoice-request.json (shortened)
{
  "html": "<table><thead><tr><th>Description</th><th>Qty</th><th>Amount</th></tr></thead><tbody>{% for line in lines %}<tr><td>{{ line.description }}</td><td>{{ line.qty }}</td><td>{{ line.amount | money: \"USD\" }}</td></tr>{% endfor %}</tbody></table>",
  "css": "tr { break-inside: avoid; } th, td { padding: 6px 8px; }",
  "data": {
    "lines": [
      { "description": "Line 1", "qty": 1, "amount": 120 },
      { "description": "Line 2", "qty": 2, "amount": 80 }
    ]
  },
  "mode": "print"
}

Variations and edge cases

Two header rows

A <thead> may hold more than one row, for example a group label above the column names. Both rows repeat. Keep the whole header short: it takes space on every page.

Column widths that jump between pages

When each page is laid out separately, columns can change width from one page to the next. Set widths on a <colgroup>, or use table-layout: fixed with widths on the header cells. In print mode CastPDF repeats the <colgroup> with the header, so the widths match.

A footer row on every page

A <tfoot> repeats at the bottom of each page in many engines. That suits a "continued on next page" line, but not a grand total. Put the real total after the table, in its own block.

Rows taller than a page

A single row with a very long note cannot fit on one page, whatever the CSS says. In print mode CastPDF splits such a row across pages so no text is lost. Shorter rows always move to the next page whole.

How CastPDF handles it

CastPDF renders your HTML in Chromium. In print mode, Paged.js lays the pages out first, and our own handler repeats every <thead> and <colgroup> on the pages a table continues onto. It is part of the renderer’s automated tests, with a 60-row invoice that spans several pages.

Every document also gets tr, img, figure, .keep-together { break-inside: avoid; }, so rows and images stay whole by default. You can try it with a free test key, or start from the invoice template, which already has a repeating header. The print CSS guide has the full rules, and the page breaks guide covers the rest of the layout.

FAQ

Common questions

Why does my table header not repeat when I print to PDF?

Usually because the header row is not inside a thead, or because CSS turned the table into a block, flex or grid element. Browsers only repeat rows that sit in a table header group.

Does Chrome repeat table headers in PDFs?

Chromium repeats a thead when it prints a normal CSS table. It cannot repeat one if the header row is in the tbody or if the table is styled as a block. Paged.js on its own does not repeat it, which is why CastPDF adds a handler in print mode.

How do I stop table rows from splitting across pages?

Add tr { break-inside: avoid; } to your CSS. A row then moves to the next page whole. CastPDF adds this rule to every document for you.

Can I repeat a table footer on every page too?

A tfoot repeats at the bottom of each page in many engines. Use it for a short note such as continued on next page, and put the grand total after the table instead.

Does this work with wkhtmltopdf?

wkhtmltopdf uses an old WebKit engine whose GitHub repository was archived in 2023, and its handling of long tables is a frequent complaint. If you are moving away from it, our wkhtmltopdf alternatives page compares the options.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.