HTML to PDF Best Practices in 2026
Pixel-perfect PDF output starts with the right HTML and CSS. Here's how to handle fonts, page breaks, images, headers, and print media queries so your PDFs look the same every time.
Generating a PDF from HTML sounds simple until you ship it. Fonts go missing, images blur, page breaks split a table row in half, and your invoice ends up with a stray half-line dangling at the bottom of page 3. Most of these problems come from treating a PDF like a web page when it's really a fixed-canvas document.
The good news: modern print CSS gives you precise control over every one of these. This post is the checklist we wish every developer had before shipping their first HTML-to-PDF integration. Every tip works with any Chromium-based renderer, including HTML2DocHub, Puppeteer, and Playwright.
1. Set paper size and margins with @page
The single highest-leverage thing you can do is declare the page size explicitly in CSS. Don't rely on renderer defaults — they drift between versions.
@page {
size: A4; /* or "Letter", "Legal", "210mm 297mm" */
margin: 18mm 15mm 20mm 15mm; /* top right bottom left */
}@page is understood by every modern rendering engine, and the margins you declare here are what the PDF will actually use, overriding any body padding you have. If you need a different first page (e.g., a cover), use@page :first.
2. Control page breaks explicitly
Renderers break pages where they can. That's usually wrong — they don't know a heading should never be the last line on a page, or that a table row must stay intact. Tell them:
/* Never split an invoice line across pages */
.invoice-row { break-inside: avoid; }
/* Always start a new section on a fresh page */
.page-break { break-before: page; }
/* Keep a heading with the content that follows */
h2, h3 { break-after: avoid; }Prefer the modern break-before / break-after /break-inside properties over the legacypage-break-* equivalents — they're the spec now and both Chromium and Firefox prefer them. Older page-break-*still works as a fallback.
3. Embed the fonts you actually want
PDFs don't have access to the fonts installed on the reader's machine — the font has to either be embedded in the PDF or substituted. Web fonts work as long as they load before the renderer takes its snapshot.
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
/>Two gotchas:
- Wait for fonts to load. If your renderer snapshots before
document.fonts.readyresolves, you'll get system fonts. HTML2DocHub waits onnetworkidleby default, which covers this for most pages. - Always declare a fallback stack.
font-family: "Inter", -apple-system, system-ui, sans-serif;means the document still looks sane if the web font fails.
4. Make images work in print
Three things to check for every image in a PDF-bound template:
- Resolution. Print is ~150–300 DPI. A 400px-wide logo will look fuzzy. Use an SVG or a 2–3x raster.
- Sizing. Use
widthandheightattributes or explicit CSS dimensions — not justmax-width: 100%. A Chromium renderer will reflow if an image loads late and changes size. - URLs. Use absolute HTTPS URLs or data URIs. Relative paths break because the renderer doesn't share your page's origin.
<img
src="https://cdn.example.com/logo-2x.png"
width="120"
height="36"
alt="Acme Corp logo"
/>5. Use @media print to shed screen styles
The print media query is your escape hatch for anything that's useful on screen but noise on paper — navigation, cookie banners, animations, hover states, dark backgrounds that eat toner.
@media print {
nav, .cookie-banner, .no-print { display: none; }
body { background: white !important; color: black; }
a { text-decoration: none; color: inherit; }
a[href]:after { content: " (" attr(href) ")"; }
}Chromium applies print styles when rendering to PDF, so this block runs the way you expect — no emulation hacks needed.
6. Add repeating headers and footers
For multi-page documents you usually want a header on every page (company name, page number, job ID). Two approaches:
- CSS
@pagerunning elements. Standards- compliant but not yet supported in Chromium. Skip this unless you're targeting a different engine. - Renderer header/footer templates. HTML2DocHub (and Puppeteer / Playwright) expose a
header_templateandfooter_templateoption where you can use the.pageNumberand.totalPagesclasses. This is the approach we recommend.
{
"html": "<h1>Invoice #INV-0042</h1>",
"options": {
"format": "A4",
"margin": { "top": "25mm", "bottom": "20mm" },
"display_header_footer": true,
"header_template": "<div style='font-size:9px;width:100%;text-align:right;padding:0 15mm'>Invoice INV-0042</div>",
"footer_template": "<div style='font-size:9px;width:100%;text-align:center'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
}
}7. Template, don't string-concatenate
If you're building HTML by gluing strings together, you're one unescaped & away from a broken render. Use a real templating engine:
- Python: Jinja2, Mako
- Node: Handlebars, EJS, Nunjucks, React SSR
- PHP / Laravel: Blade
- Ruby / Rails: ERB, Slim
- Go:
html/template(nevertext/templatefor user-facing HTML)
Any of these auto-escape user input. That's not just an XSS concern — a stray < in a product description will silently break the layout when it reaches Chromium.
8. Test your PDFs in CI
Visual regressions in PDFs are brutally easy to miss. A one-pixel shift in your header template can push a page-break onto a new page and double your document length. Two lightweight tests that catch ~90% of regressions:
- Assert page count. Render a known-good invoice and check the output has exactly N pages.
- Pixel-diff the first page. Render, rasterise page 1 to PNG (Chromium can do this natively, or use
pdf-lib/pdf2pic), compare with a baseline usingpixelmatchor similar. Flag anything above a small pixel threshold.
Both are five-line tests. Both will eventually save you from shipping a PDF that says "Total: $" to a paying customer.
Closing
Most "my PDF looks weird" bugs trace back to one of the seven points above. Nail those and the rest of the layout is just regular CSS.
If you want to try these recipes without setting up your own Puppeteer stack, HTML2DocHub exposes the same Chromium engine as a REST API — start for free with the included starter credit and you can render a few hundred pages before you top up.
Try HTML2DocHub free
New accounts get free starter credit — enough to render around 100 pages before you top up. No subscription, no card on file.
Create account