HTML to PDF: Headers and Footers Not Working Properly

Headers and footers break in PDF generation because CSS paged media uses a different rendering context than browsers, position: fixed doesn't work the same way, and page numbering requires CSS counters that most tools don't fully support. After a few test renders, you'll notice headers overlap content, footers don't appear, or page numbers repeat incorrectly—especially with "Page X of Y" formatting.

Why This Happens

PDF rendering uses the CSS Paged Media specification, which defines special @page rules for headers, footers, and margins. This is a completely different context than browser rendering:

  1. Different rendering context: Browser CSS (position: fixed) attaches elements to the viewport. PDF CSS attaches to the page box model, which doesn't have a viewport.
  2. Page boxes vs viewport: Each PDF page is a separate "page box" with its own coordinate system. What works in a scrolling browser fails when split into discrete pages.
  3. Margin boxes: CSS Paged Media uses margin boxes (@top-center, @bottom-left, etc.) to place headers and footers. Most HTML-to-PDF tools support this partially or not at all.
  4. Page counters: Page numbering requires CSS counters (counter-increment, counter(page)) which work differently across tools.
  5. No DevTools: You can't inspect or debug PDF rendering context like you can in Chrome DevTools, making trial-and-error slow.

Common Issues Developers Face

Headers and Footers Don't Appear

The most common problem: you add header/footer HTML with CSS, but nothing renders in the PDF.

Why it happens:

  • position: fixed doesn't work in paged media context
  • Content flows into header/footer areas defined by @page margins
  • Z-index doesn't prevent content overlap

Content Overlaps with Headers

You add a header, but document content prints over it.

Why it happens:

  • @page margin rules need to match header height exactly
  • If margin is too small, content flows into header area
  • Different tools handle margin calculation differently

Page Numbers Don't Work

Simple counter(page) works, but "Page X of Y" fails or shows incorrect totals.

Why it happens:

  • counter(pages) (total pages) requires two-pass rendering
  • Not all tools support counter(pages)
  • Running elements (content that appears on every page) are complex

Different First Page Headers

You want different headers on page 1 vs other pages (common for invoices, reports).

Why it happens:

  • :first pseudo-class support varies
  • @page :first rules are part of CSS Paged Media Level 3 (not widely supported)
  • Requires conditional logic most tools don't provide

Why CSS Solutions Are Brittle

Here's a typical CSS @page attempt:

@page {
  margin-top: 2cm;
  margin-bottom: 2cm;
  
  @top-center {
    content: "Company Name | Invoice";
    font-size: 12px;
  }
  
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
  }
}

Problems with this approach:

  1. Browser support: Works in Chrome print preview, fails in Puppeteer
  2. Tool variance: wkhtmltopdf ignores @top-center entirely, Playwright supports it partially
  3. Styling limits: Margin box content is plain text, no complex HTML/styling
  4. Debugging nightmare: No way to inspect why footer doesn't appear
  5. Calculation errors: If header height changes, you must manually update margin-top

The Puppeteer Approach (And Its Limits)

Puppeteer provides a displayHeaderFooter option that bypasses CSS:

// Puppeteer header/footer approach
const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.setContent(htmlContent);

await page.pdf({
  path: 'invoice.pdf',
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate: `
    <div style="font-size: 10px; width: 100%; text-align: center;">
      <span class="title"></span>
    </div>
  `,
  footerTemplate: `
    <div style="font-size: 10px; width: 100%; text-align: center;">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span>
    </div>
  `,
  margin: {
    top: '2cm',
    bottom: '2cm'
  }
});

await browser.close();

This works, but has serious limitations:

  1. Minimal styling: Only inline styles, no external CSS
  2. Limited data: Only pageNumber, totalPages, title, url, date available
  3. No dynamic content: Can't include invoice number, customer name, etc. in header
  4. Fixed for all pages: Can't have different first page headers
  5. Still requires Chrome: Inherits all Puppeteer memory/timeout issues

Why Running Elements Don't Scale

CSS Paged Media Level 3 defines "running elements"—content that flows from the document into headers/footers:

/* Define running element */
h1 {
  position: running(header);
}

/* Use it in header */
@page {
  @top-center {
    content: element(header);
  }
}

Why this fails in production:

  1. Almost no tool support: Prince XML supports it ($4000 license), Puppeteer doesn't
  2. Complex interactions: When element appears/disappears affects which page it runs on
  3. Content extraction: Tool must extract element from flow and duplicate it on every page
  4. State management: Tool must track which running element is "current" as pages render

The Template-Based Solution

Instead of fighting CSS paged media, define header/footer regions in a reusable template:

Architecture

Template Definition (one time):
  ├── Header Region: Logo + Invoice Number + Date
  ├── Content Region: Line items, totals, notes
  └── Footer Region: Page numbers + company info

PDF Generation (thousands of times):
  POST /api/v1/pdf
  {
    "templateId": "invoice-template",
    "data": {
      "invoiceNumber": "INV-1234",
      "date": "2025-01-15",
      "items": [...]
    }
  }

Before (Complex CSS that doesn't work reliably)

<!DOCTYPE html>
<html>
<head>
  <style>
    /* Attempt to use CSS paged media */
    @page {
      margin-top: 3cm;
      margin-bottom: 2.5cm;
      
      @top-left {
        content: "Invoice " attr(data-invoice-number);
      }
      
      @top-right {
        content: string(invoice-date);
      }
      
      @bottom-center {
        content: "Page " counter(page) " of " counter(pages);
      }
    }
    
    /* Doesn't work - can't access dynamic data */
    .invoice-header {
      position: running(header);
    }
    
    /* Content positioning conflicts */
    body {
      margin-top: 3cm; /* Must match @page margin exactly */
    }
  </style>
</head>
<body data-invoice-number="INV-1234">
  <!-- Header content that should repeat -->
  <div class="invoice-header">
    <img src="logo.png" />
    <span>Invoice INV-1234</span>
    <span id="invoice-date">2025-01-15</span>
  </div>
  
  <!-- Main content -->
  <table class="line-items">
    <!-- ... -->
  </table>
</body>
</html>

Result: Header doesn't appear, page numbers show "Page 1 of 1" on every page, or content overlaps footer.

After (Template with defined regions)

// 1. Define template once with visual editor (or via API)
const template = {
  header: {
    height: '3cm',
    content: [
      { type: 'image', key: 'companyLogo', width: '100px' },
      { type: 'text', key: 'invoiceNumber', style: 'bold' },
      { type: 'text', key: 'invoiceDate', align: 'right' }
    ]
  },
  content: {
    fields: [
      { type: 'table', key: 'lineItems', columns: ['description', 'qty', 'price', 'total'] },
      { type: 'text', key: 'notes' }
    ]
  },
  footer: {
    height: '2cm',
    content: [
      { type: 'pageNumber', format: 'Page {current} of {total}' },
      { type: 'text', value: 'Company Ltd. | contact@company.com' }
    ]
  }
};

// 2. Generate PDF by sending data (no HTML, no CSS)
const response = await fetch('https://api.hundreddocs.com/v1/pdf', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.HUNDRED_DOCS_API_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    templateId: 'invoice-template', // References template created above
    data: {
      companyLogo: 'https://company.com/logo.png',
      invoiceNumber: 'INV-1234',
      invoiceDate: '2025-01-15',
      lineItems: [
        { description: 'Consulting', qty: 10, price: 150, total: 1500 },
        { description: 'Development', qty: 20, price: 120, total: 2400 }
      ],
      notes: 'Payment due in 30 days'
    }
  })
});

const pdfBuffer = await response.arrayBuffer();
// Headers, footers, page numbers all work correctly

Why this works:

  1. Consistent rendering: Template engine handles header/footer placement
  2. Dynamic content: Invoice number, date, customer name appear in header automatically
  3. Accurate page numbers: Engine calculates total pages before rendering
  4. Different first page: Template can define separate first-page header
  5. No CSS debugging: Define layout once, works for all PDFs

When to Use Each Approach

Use CSS @page Rules When:

  • Generating <10 PDFs per month
  • Okay with brittle CSS that requires manual testing
  • Need full control over every pixel
  • Have time to debug across different tools/browsers

Use Puppeteer displayHeaderFooter When:

  • Need simple page numbers only
  • Already using Puppeteer for other reasons
  • Headers don't need dynamic data from document
  • Okay with memory leaks and timeouts (see Puppeteer PDF Memory Leak)

Use Template-Based APIs When:

  • Generating 100+ PDFs per day
  • Headers need dynamic content (invoice numbers, dates, customer names)
  • Need "Page X of Y" to work reliably
  • Want different first-page headers
  • Can't afford CSS debugging time
  • Designers (not developers) should control layout

Migration Path from CSS to Templates

Step 1: Identify dynamic content in your current header/footer

// What data appears in headers?
const headerData = {
  invoiceNumber: 'INV-1234',
  customerName: 'Acme Corp',
  date: '2025-01-15'
};

Step 2: Create template with header/footer regions

  • Use visual editor or API to define regions
  • Map data keys to template fields
  • Test with sample data

Step 3: Replace HTML generation with API call

// Before: generate complex HTML with embedded CSS
const html = generateInvoiceHTML(data);
const pdf = await puppeteer.pdf(html);

// After: send data to template
const pdf = await fetch('/api/v1/pdf', {
  method: 'POST',
  body: JSON.stringify({ templateId, data })
});

Step 4: Remove CSS @page rules and Puppeteer setup

  • Delete @page CSS blocks
  • Remove puppeteer.launch() code
  • Delete Chrome binary from deployment

Performance Comparison

ApproachSetup TimePer-PDF LatencyMemory UsageDebugging Time
CSS @page rules4-8 hours3-10s200-500MB2-4 hours
Puppeteer displayHeaderFooter2-4 hours3-8s200-500MB1-2 hours
Template API30 min<1s~0MB<15 min

Technical takeaway: CSS paged media (@page rules, margin boxes, running elements) has poor tool support and no debugging workflow. If you need dynamic headers/footers with page numbers, template-based rendering avoids CSS entirely by defining regions once and injecting data at render time.