Get started
Handlebars templates for PDF generation: the complete guide

Handlebars templates for PDF generation: the complete guide

Learn how to use Handlebars as a PDF templating language. Variables, blocks, helpers, partials, escaping, and a full invoice example you can run today.

benoitded18 min read

Handlebars is the template language most PDF pipelines settle on. It is small (about 80 KB minified), has no runtime dependencies, escapes HTML by default, and compiles templates to plain JavaScript functions that run in microseconds. For PDF generation, where you assemble HTML once and render it with a headless browser, those four properties matter more than any feature comparison chart.

This guide covers everything you need to use Handlebars as a PDF templating language: variable interpolation, nested data access, blocks, the built-in helpers PDF4.dev ships, partials, escaping rules, error handling for missing variables, and a full invoice template you can copy and run.

Why Handlebars fits PDF generation

Handlebars works well for PDFs because the workload is one-shot rendering, not interactive UI. You compile the template once, feed it a data object, and the output goes straight to a headless browser that turns the HTML into a PDF. There is no diffing, no reactivity, no client-side hydration to worry about. A template engine that does exactly one thing fast is the right tool.

The four properties that matter:

  • Logic-less by design. Templates contain only placeholders, blocks, and helper calls. Business logic stays in the host language. This makes templates safe to give to non-engineers and easy to review.
  • HTML-escape by default. Every {{variable}} is escaped before it lands in the output. User-controlled data cannot break out of an attribute or inject a <script> tag without you opting in via {{{triple}}}.
  • Compiled, not interpreted. Handlebars.compile(template) returns a JavaScript function. Subsequent renders run the function directly. There is no parser overhead per request.
  • Zero runtime dependencies. The npm package has nothing in dependencies. It works in Node, Bun, Deno, and the browser without polyfills.

Basic variable interpolation

Variables are wrapped in double curly braces. Handlebars looks them up on the data object passed at render time and inserts the string value. Anything missing renders as an empty string in the default mode.

<h1>Hello {{name}}</h1>
<p>Your invoice number is {{invoice_number}}.</p>

Run it:

import Handlebars from "handlebars";
 
const template = Handlebars.compile(
  "<h1>Hello {{name}}</h1><p>Order #{{order}}.</p>"
);
 
const html = template({ name: "Marie", order: "ORD-2026-0042" });
// → "<h1>Hello Marie</h1><p>Order #ORD-2026-0042.</p>"

The variable name must match the property on the data object exactly. Handlebars is case-sensitive: {{Name}} and {{name}} are different lookups.

Nested object access

Use dot notation to reach into nested objects. This is the most common pattern in PDF templates because invoice data, customer records, and order items are almost always nested.

<p>Bill to: {{customer.name}}</p>
<p>{{customer.address.street}}</p>
<p>{{customer.address.city}}, {{customer.address.zip}}</p>
<p>Email: {{customer.email}}</p>

If customer.address is undefined, {{customer.address.city}} renders as an empty string instead of throwing. This is the safe default but it can hide bugs. Switch to strict mode (Handlebars.compile(src, { strict: true })) when you want missing properties to fail loudly.

Blocks: each, if, unless

Handlebars has three built-in block helpers. They cover roughly 95% of the logic you need in a PDF template.

{{#each}} for arrays

{{#each}} iterates over an array and renders the block once per item. Inside the block, this refers to the current item, and a few special variables are available: @index (zero-based position), @first (true on the first item), @last (true on the last item), and @key (when iterating an object).

<table>
  <thead>
    <tr><th>#</th><th>Item</th><th>Qty</th><th>Price</th></tr>
  </thead>
  <tbody>
    {{#each line_items}}
      <tr>
        <td>{{@index}}</td>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>{{formatCurrency price "USD"}}</td>
      </tr>
    {{/each}}
  </tbody>
</table>

You can reach the parent scope from inside an each block with ../. This is useful when the rows need data from the outer object:

{{#each line_items}}
  <tr>
    <td>{{description}}</td>
    <td>{{../currency}} {{price}}</td>
  </tr>
{{/each}}

{{#if}} and {{else}}

{{#if}} renders the block when the value is truthy. Falsy values are false, undefined, null, "", 0, and []. Note that 0 is falsy, which trips up beginners writing things like {{#if quantity}} for line items where zero is a real value.

{{#if discount}}
  <p>You saved {{formatCurrency discount "USD"}}.</p>
{{else}}
  <p>No discount applied.</p>
{{/if}}

To compare values you need a helper, because {{#if a == b}} is not valid Handlebars. PDF4.dev ships eq, neq, gt, lt, gte, and lte precisely for this:

{{#if (gt total 1000)}}
  <p class="highlight">Eligible for free shipping.</p>
{{/if}}

{{#unless}} for negation

{{#unless}} is {{#if}} inverted. Use it when negation reads more naturally than if not:

{{#unless paid}}
  <p class="due">Payment due by {{formatDate due_date "long"}}.</p>
{{/unless}}

Built-in helpers in PDF4.dev

PDF4.dev registers ten Handlebars helpers globally, available in every template you render through the API. They cover number formatting, dates, currency, text case, comparisons, math, and zero-padding. You do not need to install or register anything; they are always there.

HelperSignatureExample
formatNumbervalue, locale?{{formatNumber 1234567 "en-US"}}1,234,567
formatDatevalue, format?, locale?{{formatDate created_at "long"}}May 5, 2026
formatCurrencyvalue, currency?, locale?{{formatCurrency 99.5 "EUR" "fr-FR"}}99,50 €
uppercasevalue{{uppercase status}}PAID
lowercasevalue{{lowercase email}}[email protected]
padStartvalue, length, char?{{padStart number 6 "0"}}000042
eq / neqa, b{{#if (eq status "paid")}}…{{/if}}
gt / lt / gte / ltea, b{{#if (gt total 1000)}}…{{/if}}
matha, operator, b{{math subtotal "+" tax}} → numeric sum

formatDate accepts four format strings: short (default), long, iso, and a custom token string like "dd MMM yyyy" that recognizes yyyy, MM, MMMM, MMM, and dd. formatCurrency defaults to USD and en-US if you omit the locale.

A few real examples from PDF templates in production:

<p>Issued on {{formatDate issued_at "long" "fr-FR"}}.</p>
<p>Total: {{formatCurrency total currency "en-US"}}</p>
<p>Reference: INV-{{padStart number 6}}</p>
<p>Status: {{uppercase status}}</p>
{{#if (lte days_to_payment 0)}}
  <p class="overdue">Payment overdue.</p>
{{/if}}

The math helper is intentionally minimal. It supports +, -, *, /, and %. Anything more complex (rounding, percentages, tax breakdowns) belongs in your application code, where you can unit-test it. Pre-compute the values, pass them as plain numbers, and let the template stay logic-free.

The helpers ship by default in every PDF4.dev render. They are registered server-side at module load time in lib/pdf.ts, so they cost zero setup. If you self-host Handlebars, copy the same helper definitions into your project and call Handlebars.registerHelper once at startup.

Escaping: {{var}} vs {{{var}}}

This is the single most important security rule when using Handlebars. Every {{double-brace}} expression is HTML-escaped before it is inserted. Triple braces ({{{raw}}}) skip escaping and emit the value as-is.

Escaping replaces &, <, >, ", ', `, and = with their HTML entity equivalents. This stops a malicious customer name like <script>alert(1)</script> from running when the PDF is rendered, and it stops a quote in an address from breaking out of an attribute and overriding a class.

<!-- Safe by default -->
<p>Customer: {{customer_name}}</p>
 
<!-- DANGER: only use for trusted HTML you generated yourself -->
<div class="bio">{{{customer_bio_html}}}</div>

Rules to live by:

  1. Use {{double}} everywhere by default. It is the safe choice and what you want 99% of the time.
  2. Use {{{triple}}} only for HTML that came from your own code, like a sanitized rich-text editor output you have already cleaned with a library like dompurify.
  3. Never use {{{triple}}} on user input. A PDF rendered by Chromium will execute inline <script> tags during rendering. Even if the final PDF cannot run scripts, the rendering pass can: the attacker has a few hundred milliseconds inside your headless browser, which is more than enough to exfiltrate environment variables via fetch().
  4. Sanitize user-controlled HTML before passing it in. If a customer can write rich text that ends up in a PDF, run it through dompurify server-side first.

Partials: composable templates

A partial is a reusable template fragment you can embed inside other templates. They are perfect for shared headers, footers, address blocks, and repeated card layouts.

import Handlebars from "handlebars";
 
Handlebars.registerPartial(
  "address",
  `
  <div class="address">
    <strong>{{name}}</strong><br>
    {{street}}<br>
    {{city}}, {{zip}}<br>
    {{country}}
  </div>
`
);
 
const invoice = Handlebars.compile(`
  <h2>Bill to</h2>
  {{> address customer}}
 
  <h2>Ship to</h2>
  {{> address shipping}}
`);
 
const html = invoice({
  customer: { name: "Acme", street: "1 Main", city: "Paris", zip: "75001", country: "France" },
  shipping: { name: "Acme Warehouse", street: "10 Dock", city: "Le Havre", zip: "76600", country: "France" },
});

{{> address customer}} invokes the address partial with customer as its data context. Inside the partial, {{name}} resolves against customer.name. You can also pass extra parameters: {{> address customer label="Primary"}} makes label available inside the partial.

PDF4.dev offers a higher-level alternative through its component system (<pdf4-header>, <pdf4-footer>, <pdf4-block> tags). Components are stored in the database, version-controlled in the dashboard, and reused across templates without re-uploading the partial each time. Both approaches compose; you can use Handlebars partials inside a PDF4.dev component and the component inside a template.

Handling missing variables

The default behavior is silent: missing variables render as an empty string. This is forgiving but can mask bugs where a typo in a property name produces a blank field on every invoice for a week before anyone notices.

There are two ways to make missing variables loud.

Strict mode throws at render time when a variable is undefined. It is a per-template setting, applied at compile time:

const template = Handlebars.compile(source, { strict: true });
 
// Throws: "user.email" not defined in [object Object]
template({ user: { name: "Alice" } });

A custom helper can wrap a value and validate it without changing the template syntax:

Handlebars.registerHelper("required", (value, options) => {
  if (value == null || value === "") {
    throw new Error(`Missing required field: ${options.hash.name}`);
  }
  return value;
});

Then in the template:

<p>Total: {{required total name="total"}}</p>

Strict mode is the simpler choice for new templates. Use the custom helper pattern when you need stricter rules (non-empty string, positive number) or when only some fields are required.

Comparison with other template engines

Handlebars is not the only option. Mustache, EJS, and Liquid all generate strings from templates and would all work for PDF rendering. Here is how they compare on the dimensions that matter for PDF pipelines:

EngineLogic in templatesHTML-escape defaultHelpers / filtersCompile-timeBest for
HandlebarsLimited (helpers only)YesCustom helpers, easyCompiled to JS functionPDF templates, server rendering
MustacheNoneYesNone (logic-less)InterpretedStrict logic-less templates
EJSFull JavaScriptNo (manual)Inline JSCompiled to JS functionQuick prototypes, full logic
LiquidLimited (filters)Yes (when configured)Filters, easy to addInterpretedUntrusted templates (Shopify)
Pug (Jade)LimitedYesMixinsCompiledHTML-only, terse syntax

Handlebars sits in a sweet spot: more powerful than Mustache (you can register helpers and use if), safer than EJS (no arbitrary JavaScript in templates), and faster than Liquid (compiled, not interpreted). For PDF generation specifically, it is the default choice in most production setups.

EJS is tempting because you can write <%= total.toFixed(2) %> and skip the helper registration. The downside is that any bug in your template is now a code-injection surface. A typo can break the whole render. A malicious template can run shell commands. For PDFs that ship to customers, the strictness of Handlebars is worth the small extra ceremony.

A complete invoice template

Here is a working invoice template that uses every feature covered above: nested objects, {{#each}}, {{#if}}, helpers, conditional totals, and proper escaping. Drop it into a Node script with handlebars and playwright installed and you have an invoice generator.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Invoice {{padStart number 6}}</title>
  <style>
    body { font-family: -apple-system, sans-serif; color: #111; padding: 40px; }
    h1 { font-size: 28px; margin: 0 0 4px; }
    .meta { color: #666; font-size: 13px; }
    .row { display: flex; justify-content: space-between; margin: 32px 0; }
    table { width: 100%; border-collapse: collapse; margin-top: 16px; }
    th { text-align: left; font-size: 12px; text-transform: uppercase; color: #666; padding: 8px 0; border-bottom: 1px solid #ddd; }
    td { padding: 12px 0; border-bottom: 1px solid #eee; }
    .totals { margin-top: 24px; width: 280px; margin-left: auto; }
    .totals .line { display: flex; justify-content: space-between; padding: 4px 0; }
    .totals .grand { font-weight: 700; border-top: 2px solid #111; padding-top: 8px; margin-top: 8px; }
    .due { color: #b91c1c; font-weight: 600; margin-top: 24px; }
    .paid { color: #059669; font-weight: 600; margin-top: 24px; }
  </style>
</head>
<body>
  <h1>Invoice INV-{{padStart number 6}}</h1>
  <div class="meta">
    Issued {{formatDate issued_at "long"}} ·
    Due {{formatDate due_at "long"}}
  </div>
 
  <div class="row">
    <div>
      <strong>From</strong><br>
      {{seller.name}}<br>
      {{seller.address.street}}<br>
      {{seller.address.city}}, {{seller.address.zip}}<br>
      {{seller.email}}
    </div>
    <div>
      <strong>Bill to</strong><br>
      {{customer.name}}<br>
      {{customer.address.street}}<br>
      {{customer.address.city}}, {{customer.address.zip}}<br>
      {{customer.email}}
    </div>
  </div>
 
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Unit price</th>
        <th>Total</th>
      </tr>
    </thead>
    <tbody>
      {{#each line_items}}
        <tr>
          <td>{{description}}</td>
          <td>{{quantity}}</td>
          <td>{{formatCurrency unit_price ../currency}}</td>
          <td>{{formatCurrency line_total ../currency}}</td>
        </tr>
      {{/each}}
    </tbody>
  </table>
 
  <div class="totals">
    <div class="line">
      <span>Subtotal</span>
      <span>{{formatCurrency subtotal currency}}</span>
    </div>
    {{#if discount}}
      <div class="line">
        <span>Discount</span>
        <span>-{{formatCurrency discount currency}}</span>
      </div>
    {{/if}}
    <div class="line">
      <span>Tax ({{tax_rate}}%)</span>
      <span>{{formatCurrency tax currency}}</span>
    </div>
    <div class="line grand">
      <span>Total</span>
      <span>{{formatCurrency total currency}}</span>
    </div>
  </div>
 
  {{#if paid}}
    <p class="paid">PAID on {{formatDate paid_at "long"}}.</p>
  {{else}}
    <p class="due">Payment due by {{formatDate due_at "long"}}.</p>
  {{/if}}
</body>
</html>

A few details worth highlighting in the template above:

  • {{padStart number 6}} turns 42 into 000042 so the invoice number always renders six digits wide.
  • Inside {{#each line_items}}, ../currency reaches up one scope to grab the currency from the outer object. This is the cleanest way to format prices in a loop without duplicating the currency code on every item.
  • The {{#if discount}} block hides the discount row entirely when the value is zero or absent, so invoices without a discount do not show an awkward -$0.00 line.
  • {{#if paid}}…{{else}}…{{/if}} swaps the footer between a green "PAID" banner and a red "Payment due" notice based on a single boolean.

Caching compiled templates

Handlebars.compile() is the slowest step. Parsing the template source and producing a JavaScript function takes a few milliseconds for a typical invoice and scales with the size of the source. Calling the compiled function with data, by contrast, takes microseconds.

Cache the compiled function and reuse it across renders:

const templateCache = new Map();
 
function getCompiled(html) {
  let compiled = templateCache.get(html);
  if (!compiled) {
    compiled = Handlebars.compile(html);
    templateCache.set(html, compiled);
  }
  return compiled;
}
 
function render(html, data) {
  return getCompiled(html)(data);
}

PDF4.dev does this internally in lib/pdf.ts. Templates are keyed by their source string, compiled once on first request, and reused for the lifetime of the process. The cache hit rate for production workloads is typically above 99% because most users render the same template thousands of times with different data.

Do not key the cache by template ID alone if your users can edit templates in place. The cache key must reflect the source content so an edit invalidates the compiled function. Hashing the source (or just using the source string directly as a Map key, like the example above) is the safe pattern.

Common pitfalls

A few mistakes show up over and over in PDF templates that use Handlebars. They are easy to fix once you know the pattern.

Forgetting to escape user input. Triple braces look harmless but they are the only way Handlebars can leak HTML. Audit every {{{...}}} in your codebase and confirm the value is generated by code you trust, not user input.

Confusing {{#if value}} with a strict equality check. {{#if quantity}} is false when quantity is 0, even though 0 is a valid quantity. Use {{#if (neq quantity null)}} or restructure your data so the field is never zero when you want to show it.

Calling helpers as block helpers. Helpers like formatCurrency are inline helpers, not block helpers. {{#formatCurrency value}} will throw. Use {{formatCurrency value}} without the leading #.

Passing dates as JavaScript Date objects. Handlebars stringifies a Date with toString(), which produces Tue May 05 2026 00:00:00 GMT+0000 (UTC). Pass dates as ISO strings ("2026-05-05" or "2026-05-05T10:00:00Z") and let formatDate handle them.

Forgetting strict mode in production. Without { strict: true }, a typo in {{customre.name}} silently renders an empty string. Turn strict mode on for production templates and let CI catch typos at deploy time, not after a customer reports a blank PDF.

Try it without installing anything

If you want to skip the local setup entirely, you can paste a Handlebars template into the PDF4.dev dashboard and watch it render in real time. Variables show up as purple pills in the editor, sample data is editable in the sidebar, and every change re-renders the preview.

Convert HTML with Handlebars to PDF onlineTry it free

For production use, the same template can be saved and rendered by API call from any language. The render path uses the helpers documented above and adds Google Fonts caching, browser pooling, and signed delivery URLs out of the box.

FAQ

Why use Handlebars for PDF templates instead of string concatenation?

Handlebars separates the template from the data, escapes HTML by default to prevent injection, and supports loops and conditionals declaratively. String concatenation hand-rolls all of this and is the most common source of XSS bugs in PDF pipelines.

What is the difference between {{var}} and {{{var}}}?

Double braces escape HTML special characters (<, >, &, ", ') so user input cannot break out of an attribute or inject script tags. Triple braces output the raw value and should only be used for trusted HTML you generated yourself.

Can Handlebars access nested object properties?

Yes. Use dot notation: {{user.address.city}}. Missing intermediate properties resolve to an empty string in the default mode and throw in strict mode.

Does Handlebars support if/else and loops?

Yes. {{#if condition}}...{{else}}...{{/if}} for conditionals, {{#each items}}...{{/each}} for arrays, and {{#unless condition}}...{{/unless}} for negation. Inside an each block, {{@index}}, {{@first}}, {{@last}}, and {{this}} are available.

How does Handlebars handle missing variables?

By default, missing variables render as an empty string silently. Compile with { strict: true } to make them throw at render time. Use strict mode in production to catch typos early.

Is Handlebars faster than other template engines?

Handlebars compiles templates to JavaScript functions on first use, then reuses the compiled function for every render. After the first call, it runs at near-string-concatenation speed. Caching the compiled template is the single biggest performance win.

Can I add my own helpers to Handlebars?

Yes. Handlebars.registerHelper("name", fn) makes a helper available to every template compiled afterward. PDF4.dev ships ten built-in helpers so you do not have to write the common ones yourself.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.