ScreenshotNeo

BlogHTML to image & PDF

How to Add Fixed Headers and Footers to HTML-to-PDF Output

Add repeating headers, footers, and page numbers to HTML-to-PDF files with Puppeteer, WeasyPrint, or wkhtmltopdf.

By the ScreenshotNeo team30 September 20268 min read

How to Add Fixed Headers and Footers to HTML-to-PDF Output

Direct answer: a fixed header or footer in a multi-page PDF is renderer-specific. First identify the engine that creates the PDF, then use its page-margin mechanism: Puppeteer provides PDF header/footer templates, WeasyPrint uses CSS paged-media margin boxes and running elements, and wkhtmltopdf provides header/footer options and page-number tokens. A browser element with position: fixed is not a universal solution for paginated output.

Reserve enough top and bottom page margin for the recurring content, generate a PDF, and inspect the first, a middle, and the final page. Pagination, page breaks, and renderer versions can change the result.

1. Choose the PDF renderer before writing CSS

These engines expose different controls:

The renderer determines how recurring page regions are created.
The renderer determines how recurring page regions are created.
Renderer Recommended mechanism Page numbers Primary concern
Puppeteer / Chromium displayHeaderFooter, headerTemplate, footerTemplate, and PDF margins Template classes such as pageNumber and totalPages in supported versions Page.pdf() uses print media by default
WeasyPrint CSS @page margin boxes, page counters, running elements, and named strings counter(page) and related paged-media features Feature support and documented counter limitations vary by version
wkhtmltopdf CLI header/footer switches or an HTML header/footer document [page] and [topage] Margins and header spacing must be configured together

Read the API reference for the exact version installed in production: Puppeteer documentation, WeasyPrint documentation, and wkhtmltopdf documentation.

2. Puppeteer: repeating HTML templates on every page

Puppeteer renders the header and footer separately from the document body. Set displayHeaderFooter to true, provide templates, and reserve space with PDF margins.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();

await page.setContent(`
  <!doctype html>
  <html>
    <head>
      <style>
        @page { margin: 0; }
        body {
          font-family: Arial, sans-serif;
          margin: 0;
          color: #222;
        }
        .report { padding: 0; }
        h1 { page-break-after: avoid; }
      </style>
    </head>
    <body>
      <main class="report">
        <h1>Quarterly report</h1>
        ${'<p>Report content repeated to demonstrate pagination.</p>'.repeat(80)}
      </main>
    </body>
  </html>`, {waitUntil: 'networkidle0'});

// Page.pdf() uses print CSS by default. Use screen media only when required.
// await page.emulateMediaType('screen');

await page.pdf({
  path: 'report.pdf',
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  headerTemplate: `
    <div style="width:100%; font-size:9px; padding:0 18mm; color:#555;">
      Quarterly report
    </div>`,
  footerTemplate: `
    <div style="width:100%; font-size:9px; padding:0 18mm; color:#555; text-align:right;">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span>
    </div>`,
  margin: {
    top: '22mm',
    bottom: '18mm',
    left: '18mm',
    right: '18mm'
  }
});

await browser.close();

The template area is not automatically allowed to overlap body content. Increase margin.top or margin.bottom when a header or footer is clipped. Puppeteer’s PDF method uses the print CSS media type by default; call page.emulateMediaType('screen') before page.pdf() only when screen styles are intended. Chromium may alter printed colors; use -webkit-print-color-adjust: exact when exact colors are required.

Puppeteer options that affect recurring regions

  • displayHeaderFooter: enables the template regions.
  • headerTemplate and footerTemplate: small HTML fragments rendered on each page.
  • margin.top and margin.bottom: reserve physical space.
  • format, paperWidth, paperHeight, and landscape: change available width and pagination.
  • printBackground: keeps background fills and images in printed output.
  • preferCSSPageSize: lets CSS @page size take precedence where supported by your version.
  • pageRanges: exports selected pages; verify how your installed version numbers the resulting document.

Keep templates self-contained. Inline the styles they need, avoid relying on body selectors, and use a font size that fits inside the reserved margin.

3. WeasyPrint: CSS paged media, margin boxes, and running elements

WeasyPrint supports CSS paged-media features including @page, page margin boxes, page-based counters, named pages, page selectors, running elements, and named strings. This makes the recurring content part of the document’s page model rather than a fixed element in normal flow.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page {
      size: A4;
      margin: 24mm 18mm 20mm;
      @top-center {
        content: element(report-header);
      }
      @bottom-center {
        content: element(report-footer) "  |  Page " counter(page);
      }
    }

    .running-header {
      position: running(report-header);
      font: 9pt Arial, sans-serif;
      color: #555;
      border-bottom: 0.3mm solid #ccc;
      padding-bottom: 2mm;
    }

    .running-footer {
      position: running(report-footer);
      font: 9pt Arial, sans-serif;
      color: #555;
    }

    body { font: 11pt Arial, sans-serif; }
    h1, h2 { break-after: avoid; }
  </style>
</head>
<body>
  <header class="running-header">Quarterly report</header>
  <footer class="running-footer">Internal</footer>
  <h1>Quarterly report</h1>
  <p>Your report content goes here.</p>
</body>
</html>
from weasyprint import HTML

HTML('report.html').write_pdf('report.pdf')

The start parameter of element() is not supported in the documented WeasyPrint API. Page counters also have known limitations, so verify page-number behavior with the version deployed rather than assuming browser CSS behavior.

Changing headers by chapter

Named strings can place the current heading in a page margin:

h2 {
  string-set: chapter content();
}

@page {
  @top-center {
    content: string(chapter);
  }
}

Use this when each section needs a different running title. Test pages around chapter breaks because the heading that becomes current depends on pagination.

4. wkhtmltopdf: command-line headers, footers, and tokens

wkhtmltopdf exposes recurring content through command-line options. Its documented replacement variables include [page] for the current page and [topage] for the last page.

wkhtmltopdf \
  --margin-top 22mm \
  --margin-bottom 18mm \
  --header-left "Quarterly report" \
  --header-font-size 9 \
  --footer-right "Page [page] of [topage]" \
  --footer-font-size 9 \
  report.html report.pdf

Other documented tokens include [frompage], [webpage], [section], and [doctitle]. Header spacing and top/bottom margins are separate settings; reserve enough room for both.

For more control, provide an HTML document with elements whose classes receive page values:

<!doctype html>
<html>
<body>
  <div>Quarterly report</div>
  <div>Page <span class="page"></span> of <span class="topage"></span></div>
</body>
</html>
wkhtmltopdf \
  --margin-top 25mm \
  --margin-bottom 20mm \
  --header-html header.html \
  --footer-html footer.html \
  report.html report.pdf

Keep scripts, external assets, and CSS compatible with the exact wkhtmltopdf build. If the header document loads resources, make those resources available to the renderer process.

5. Layout rules that prevent overlap and blank space

  1. Measure the recurring content. A two-line footer needs more bottom margin than a one-line footer.
  2. Reserve margins in the renderer. Use PDF margins in Puppeteer, @page margin in WeasyPrint, and wkhtmltopdf margin and spacing options.
  3. Keep body padding separate. Body padding changes content flow but does not always reserve template space.
  4. Avoid forced breaks immediately before headings. A break can leave a large empty region while the header still repeats.
  5. Check long unbreakable content. Wide tables, code lines, and images can push content under a footer or outside the page.
  6. Use explicit image dimensions. Late image layout changes can move a page break after the footer was calculated.

6. Page numbers and “1 of 2” labels

Engine Typical mechanism What to verify
Puppeteer pageNumber and totalPages spans in the footer template Template classes and PDF options in the installed Puppeteer version
WeasyPrint counter(page) in a page margin box Total-page support and known counter limitations in your version
wkhtmltopdf [page] and [topage] Final-page value after all content and JavaScript finish

Always inspect the first, a middle, and the final page. A document that fits on one page can hide a broken total-page implementation.

7. Troubleshooting

Cause: the reserved margin is smaller than the template. Fix: increase the top or bottom margin and reduce template padding or font size.

Cause: it is a normal-flow element, or the renderer’s recurring mechanism was not enabled. Fix: use Puppeteer’s displayHeaderFooter, WeasyPrint running elements or margin boxes, or wkhtmltopdf header/footer options.

Page numbers are blank

Cause: unsupported placeholder names, an incorrect template context, or a renderer version mismatch. Fix: confirm the documented placeholders for the installed version and inspect a multi-page PDF.

Colors or backgrounds changed

Cause: print media styles or print color adjustment. Fix: in Puppeteer, choose print or screen media deliberately and use -webkit-print-color-adjust: exact where appropriate; enable background printing.

Content starts too low or pages contain excessive blank space

Cause: both page margins and body padding reserve space. Fix: keep one clear source of spacing and remove redundant top or bottom padding.

Cause: images loaded without intrinsic dimensions or after pagination. Fix: set width and height, wait for images and fonts, and only then generate the PDF.

Different machines produce different breaks

Cause: renderer, browser, font, or operating-system differences. Fix: pin the renderer version, package required fonts, and compare generated PDFs after upgrades.

8. Reliability and performance checklist

  • Pin Puppeteer/Chromium, WeasyPrint, or wkhtmltopdf versions.
  • Bundle or preload fonts and wait for web fonts before capture.
  • Use deterministic asset URLs and explicit image dimensions.
  • Set a navigation and rendering timeout appropriate for the document.
  • Generate a representative short document and a worst-case long document in CI.
  • Compare first, middle, and last pages after dependency upgrades.
  • Log the renderer version, input identifier, page count, and failure reason.
  • For large batches, reuse a browser process carefully while isolating pages and clearing per-document state.

There is no universal speed or cost ranking among these engines. Measure your own workload: HTML size, image count, font loading, JavaScript, page count, and concurrency determine the result.

9. Or skip the browser setup

If you only need a rendered PDF or image from a URL, ScreenshotNeo provides a single GET request. Its PDF options include paper size, margins, landscape mode, and page ranges, so you can keep recurring-page configuration in the capture request instead of maintaining a browser service.

A hosted capture service can clean page elements before rendering.
A hosted capture service can clean page elements before rendering.

See the ScreenshotNeo API documentation for the complete option list.

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}`);
  • Cookie banners, newsletter popups, and chat widgets are removed before the shot.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed; response headers identify the page verdict and billing status.
  • An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
  • 1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with 1,000 screenshots per month without adding a card.

10. FAQ

Sometimes, but it is renderer-dependent. Use the engine’s paged-media or PDF-template feature when you need predictable repetition.

How much margin should I reserve?

Reserve more than the rendered height of the header or footer, including padding, borders, and the font’s line height. Validate with a multi-page sample.

How do I show the total number of pages?

Use Puppeteer’s supported page-number template classes, WeasyPrint’s documented counters where they work for your version, or wkhtmltopdf’s [topage] token.

Which renderer should I choose?

Choose the renderer already required by your application or whose page-margin features match your layout. Do not choose by an assumed speed or fidelity ranking without measuring your own documents.

Pagination is recalculated whenever content length changes. Recheck page breaks, available margin, and the renderer’s total-page mechanism after any content change.