Convert HTML + Handlebars templates to PDF in Node.js using Puppeteer (headless Chromium). PhantomJS / html-pdf are no longer used as of v4.
Requirements: Node.js 18 or newer.
Install size: puppeteer downloads a compatible Chromium build on npm install (hundreds of MB). In CI you can cache the browser directory or set PUPPETEER_CACHE_DIR / PUPPETEER_SKIP_DOWNLOAD as needed.
| Strengths | HTML + Handlebars — quick to build invoices, reports, and letters without drawing coordinates. v4+ uses Chromium, so you get modern CSS (flexbox, grid, web fonts) and a maintained rendering stack — not deprecated PhantomJS. pdfChrome helps with layout, headers, footers, and copyright in one place. |
| Trade-offs | Footprint: Chromium adds install size and RAM per browser instance. Throughput: launching a browser is heavier than a pure-JS library like PDFKit for tiny one-off jobs; for high volume, reuse browsers (Puppeteer patterns), queue work, or run workers. Model: this package is HTML → PDF; if you need a drawing API (paths, precise vector control) without HTML, use PDFKit / pdf-lib instead. Print pipeline: PDFs use Chrome’s print path — edge cases can differ from on-screen CSS (true for any headless-Chrome PDF approach). |
Practical verdict: strong fit for small and medium projects and production workloads where you control memory and concurrency; for very large scale (always-on millions of PDFs/day), plan infrastructure (pooling, autoscaling, or a dedicated rendering service) like any Chromium-based pipeline.
- v4 is a major release: PDFs are rendered with Chromium, so layout can differ slightly from Phantom.
- Options are still the same shape for most fields (
format,orientation,border,header,footer,pdfChrome). - Footer
contents: only one template is applied. We usedefault, elsefirst, elselast. Per-page keys (e.g. page2) are not supported in v4. phantomPath,phantomArgs, etc. are ignored (seePdfRenderOptionsin types).{{page}}/{{pages}}in header/footer HTML are converted to Puppeteer’s print tokens automatically.
npm i pdf-creator-nodeconst pdf = require("pdf-creator-node");
const fs = require("fs");
const html = fs.readFileSync("template.html", "utf8");
const options = {
format: "A3",
orientation: "portrait",
border: "10mm",
};
const document = {
html,
data: { users: [{ name: "Ada", age: 36 }] },
path: "./output.pdf",
// type: "" // optional; omit or use "" / "file" for file output
};
pdf
.create(document, options)
.then((res) => console.log(res))
.catch((err) => console.error(err));import pdf from "pdf-creator-node";
import type { PdfDocument, PdfCreateOptions } from "pdf-creator-node";
const document: PdfDocument = {
html: "<p>{{title}}</p>",
data: { title: "Hello" },
path: "./out.pdf",
};
const options: PdfCreateOptions = {
format: "A4",
orientation: "portrait",
};
await pdf.create(document, options);- File (default): set
path. You can omittype, or usetype: ""ortype: "file". - Buffer:
type: "buffer". - Stream:
type: "stream".
Use pdfChrome on the second argument for common paper layout and repeating header/footer. Plain title / copyright strings are HTML-escaped.
layout:format,orientation,width,height,border(mapped to Puppeteer’s PDF / margin options).header:html(raw HTML per page) ortitle(centered text). Default height45mmwhen content is set.footer:htmlor combinecopyrightwith optionalshowPageNumbers({{page}}/{{pages}}). Withcopyrightonly, page numbers default on unless you setshowPageNumbers: false. For page numbers only, setshowPageNumbers: trueand omitcopyright. Default footer height28mmwhen content is set.
Anything you set directly on the options object (format, header, footer, …) overrides the matching field from pdfChrome.
pdf.create(document, {
pdfChrome: {
layout: { format: "A4", orientation: "portrait", border: "12mm" },
header: { title: "Quarterly report" },
footer: { copyright: "© 2026 My Company", showPageNumbers: true },
},
});Advanced: buildPdfChrome(pdfChrome) returns partial PdfRenderOptions if you want to compose manually (also exported).
Pass handlebarsHelpers on the second argument (alongside PDF options). Helpers apply only to that render (isolated Handlebars instance). Built-in ifCond is always registered.
pdf.create(
{
html: "<p>{{upper name}}</p>",
data: { name: "test" },
path: "./out.pdf",
},
{
format: "A4",
handlebarsHelpers: {
upper: (s) => String(s).toUpperCase(),
},
}
);You may see explicit errors such as:
document.htmlmust be a non-empty stringdocument.datais required (use{}if there are no variables)document.pathis required when writing a file (or usebuffer/stream)template rendering failed:… (Handlebars compile/render issue)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello world!</title>
</head>
<body>
<h1>User List</h1>
<ul>
{{#each users}}
<li>Name: {{this.name}}</li>
<li>Age: {{this.age}}</li>
{{/each}}
</ul>
</body>
</html>Examples:
- Size:
"height": "10.5in","width": "8in"(CSS units), or"format": "Letter"(A3, A4, A5, Legal, Letter, Tabloid) with"orientation". border: margin around the page content (string or per-side object).header/footer:contentsis HTML. In the footer, prefercontents.defaultfor the repeating footer;{{page}}and{{pages}}work as with older versions (mapped for Chromium print).
const options = {
format: "A3",
orientation: "portrait",
border: "10mm",
header: {
height: "45mm",
contents: '<div style="text-align: center;">Author: Shyam Hajare</div>',
},
footer: {
height: "28mm",
contents: {
default:
'<span style="color: #444;">{{page}}</span>/<span>{{pages}}</span>',
},
},
};Page margins are the border option (string or per-side object). That maps to Chromium’s PDF margins—the content area is inset by this amount.
v4 renders with Chromium, so fonts work like in a normal web page: @font-face, font-family, and hosted fonts.
Hosted fonts (Google Fonts, jsDelivr, etc.) — add a <link> in <head> and set font-family in your CSS. The process must be able to reach the font URL.
Local files — use @font-face with a relative url('fonts/MyFont.woff2') and set base on the options object to the folder that contains those paths (it becomes Puppeteer’s baseURL). Use a trailing slash when base is a directory:
const path = require("path");
const options = {
format: "A4",
base: path.join(__dirname, "assets") + path.sep,
};Header / footer HTML is rendered in Chromium’s print header/footer UI. It does not automatically inherit styles from the main template. To use the same font there, repeat a <link> / @font-face in the header.contents / footer.contents string, or rely on system fonts for those snippets.
Remote URLs, local files, base64 data URLs, and WebP:
- Remote:
src="https://..."works when the environment can fetch the URL. - Local files: set
baseand use a path relative to that root (same as for fonts). - Data URLs:
data:image/png;base64,...(use the correct MIME type). WebP is generally supported in Chromium; if something fails, try PNG or JPEG. - Images inside
header/footer: prefer absolutehttps://URLs, or paths that resolve correctly with yourbase; relative paths are easy to get wrong in the print template.
Wide tables or “horizontal scroll” in a browser do not carry over as scroll in a PDF—use orientation: "landscape", smaller type, or table/CSS layout that fits the page width. For keeping blocks together on one page, try CSS break-inside: avoid / page-break-inside: avoid (behavior follows Chromium’s print rules).
It can work, but not with a default “zip only” deploy in most cases. This package runs Puppeteer with headless Chromium, which is large and memory-hungry.
| Topic | Guidance |
|---|---|
| Deployment size | Default puppeteer installs a full Chromium build (hundreds of MB). AWS Lambda zip deployments have a 250 MB unzipped limit, so the stock layout often does not fit. |
| What usually works | Lambda container images (Docker) with Chromium + Node, or a trimmed Chromium packaged for Lambda (community layers / @sparticuz/chromium–style setups) with puppeteer-core. |
| This library today | puppeteer.launch() is called with --no-sandbox, --disable-setuid-sandbox, and --disable-dev-shm-usage (typical for containers and many serverless images). There is no public option to set executablePath or merge extra launch flags—using a custom Chromium binary may require a fork, patch, or a wrapper that replaces how the browser is launched until such options exist. |
| Memory & timeout | Plan for ~1.5–3 GB RAM and a timeout that covers cold start + browser launch + PDF generation. |
| Alternatives | Run PDF generation on ECS/Fargate, EC2, or a dedicated microservice; keep Lambda for orchestration only. |
For CI and non-Lambda servers, you can tune install size with PUPPETEER_SKIP_DOWNLOAD / PUPPETEER_CACHE_DIR when you supply your own browser.
Compare two values in the template:
Example with each:
<ul>
{{#each users}}
<li>
{{this.name}}
{{#ifCond this.age "===" "26"}}
(twenty-six)
{{/ifCond}}
</li>
{{/each}}
</ul>Supported operators: ==, ===, !=, !==, <, <=, >, >=, &&, ||.
Note: Only two operands are compared per ifCond.