Guides

Writing HTML templates for PDF: a style guide (Jinja, Django, Handlebars)

Templates that render well as PDFs follow a few rules: fixed page geometry, no external state, defensive filters, print CSS. Examples in Jinja and Django syntax.

M Mikel Rougstone · 31 August 2026 · 3 min read
Writing HTML templates for PDF: a style guide (Jinja, Django, Handlebars)

Web templates assume a scrolling viewport and a user who can resize. PDF templates assume a fixed page and no user. The rules are different, and templates written for the screen produce documents that are subtly wrong in ways nobody notices until a customer does.

Geometry in millimetres

Set the page in the stylesheet and design inside it:

@page { size: A4; margin: 14mm; }

A4 minus 14 mm margins leaves 182 × 269 mm of printable area. Use millimetres for anything that must line up with paper — margins, column widths, signature lines — and points or pixels for type sizes.

Avoid vh, vw and percentage heights. There is no viewport; a height: 100vh block either fills nothing or overflows the page.

No external state

The template gets everything from its context. That means:

  • No now() calls. A preview rendered at 23:59 and a document rendered at 00:01 should not carry

different dates. Pass the date in.

  • No relative URLs. Inline HTML has no base document to resolve against. Absolute URLs or data

URIs, always.

  • No fetching. Anything the template needs must arrive in the context.

Format in code, place in the template

Money, dates and numbers should arrive formatted:

# in your view or service
ctx = {
    "total": f"{order.total_cents / 100:,.2f}",
    "issued": order.issued_at.strftime("%-d %B %Y"),
    "vat_rate": f"{order.vat_rate:.0%}",
}
<!-- in the template -->
<td class="num">{{ currency }}{{ total }}</td>

Templates that do arithmetic eventually round differently from your ledger, and a one-cent discrepancy in a reconciliation report costs more time than it saves. This is also why PDFGeny's template renderer computes line amounts server-side from quantity and price rather than leaving multiplication to the template language.

Defensive defaults

Every optional field gets a fallback:

{{ client_vat|default:"—" }}                       {# Django #}
{{ client_vat or "—" }}                            {# Jinja2 #}

An empty cell is a data problem you can see. A missing cell that shifts the whole table is a template problem you find in production.

Loops for line items

{% for line in lines %}
<tr>
  <td>{{ line.description }}</td>
  <td class="num">{{ line.qty }}</td>
  <td class="num">{{ currency }}{{ line.unit }}</td>
  <td class="num">{{ currency }}{{ line.total }}</td>
</tr>
{% endfor %}

Two things to handle that fixtures never show: an empty list (print "No items" rather than an empty table), and a description long enough to wrap to three lines (does the row still fit? does break-inside: avoid still hold?).

Inside the template:

thead { display: table-header-group; }
tr, .card { break-inside: avoid; }
h2 { break-after: avoid; }

Outside it, as render options — because page numbers are not CSS:

{"header_html": "<span>Acme Ltd</span>",
 "footer_html": "Page {{page}} of {{pages}} · {{ invoice_number }}"}

More on which properties actually work in page breaks in HTML to PDF.

Escaping and untrusted data

If any field comes from a customer — a company name, an address, a note — it is untrusted input being placed into markup that a browser will execute. Django and Jinja2 autoescape by default; the danger is the |safe filter someone adds to make a line break work.

Use |linebreaksbr for multi-line fields rather than |safe, and if you genuinely need HTML from a user, sanitise it with an allowlist first.

A structure that scales

For more than two or three documents, share the furniture:

templates/
  documents/
    _base.html        # @page, fonts, shared table and typography styles
    _header.html      # logo block, seller details
    _totals.html      # subtotal / tax / grand total
    invoice.html      # extends _base, includes the parts
    receipt.html
    credit_note.html

Changing the brand font then touches one file rather than forty — which is exactly the situation PDFGeny's own 40-template catalog is built on: one shared document stylesheet, one partial per document type.

Preview at real scale

Render your longest realistic document before shipping a template, not a two-line fixture. Page breaks, repeated headers, wrapping addresses and empty optional fields only appear with real data — and they are exactly what customers notice first.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.