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:
- 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. - 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.
- 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. - Page counters: Page numbering requires CSS counters (
counter-increment,counter(page)) which work differently across tools. - 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: fixeddoesn't work in paged media context- Content flows into header/footer areas defined by
@pagemargins - Z-index doesn't prevent content overlap
Content Overlaps with Headers
You add a header, but document content prints over it.
Why it happens:
@pagemargin 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:
:firstpseudo-class support varies@page :firstrules 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:
- Browser support: Works in Chrome print preview, fails in Puppeteer
- Tool variance: wkhtmltopdf ignores
@top-centerentirely, Playwright supports it partially - Styling limits: Margin box content is plain text, no complex HTML/styling
- Debugging nightmare: No way to inspect why footer doesn't appear
- 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:
- Minimal styling: Only inline styles, no external CSS
- Limited data: Only
pageNumber,totalPages,title,url,dateavailable - No dynamic content: Can't include invoice number, customer name, etc. in header
- Fixed for all pages: Can't have different first page headers
- 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:
- Almost no tool support: Prince XML supports it ($4000 license), Puppeteer doesn't
- Complex interactions: When element appears/disappears affects which page it runs on
- Content extraction: Tool must extract element from flow and duplicate it on every page
- 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:
- Consistent rendering: Template engine handles header/footer placement
- Dynamic content: Invoice number, date, customer name appear in header automatically
- Accurate page numbers: Engine calculates total pages before rendering
- Different first page: Template can define separate first-page header
- 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
@pageCSS blocks - Remove
puppeteer.launch()code - Delete Chrome binary from deployment
Performance Comparison
| Approach | Setup Time | Per-PDF Latency | Memory Usage | Debugging Time |
|---|---|---|---|---|
| CSS @page rules | 4-8 hours | 3-10s | 200-500MB | 2-4 hours |
| Puppeteer displayHeaderFooter | 2-4 hours | 3-8s | 200-500MB | 1-2 hours |
| Template API | 30 min | <1s | ~0MB | <15 min |
Related Content
- Puppeteer PDF Alternative - Why managed APIs avoid header/footer CSS issues entirely
- HTML to PDF Flexbox Issues - More CSS limitations in PDF generation
- Invoice PDF Generation API - Complete example with headers, footers, page numbers
- JSON to PDF Architecture - How template-based rendering works
- Why Developers Should Not Code PDF Templates from Scratch - Learn why coding PDF templates leads to header/footer issues.
- Handling Dynamic Content in PDFs: Master Page Breaks and Flow - Essential for understanding how templates manage content across pages.
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.