HTML to PDF in Node.js: replace html-pdf-node
Updated by Cyril Nicodème
The generatePdf({ content: html }, options) call on html-pdf-node's npm page looks like it hands your HTML straight to Chrome. It doesn't. Your HTML first goes through a CSS inliner and a strict Handlebars template, and every call starts a new browser. Our test invoice didn't survive the first step. Keep your route and your options, and swap the package: Puppeteer with one browser you keep open, or one fetch to PDFShift.
What does html-pdf-node do to your HTML?
The whole package is one 94-line file, short enough to read in a few minutes.
1. inline-css. It copies your CSS into style attributes and then removes the <style> tag, which is what its removeStyleTags option does by default. Rules that can't be inlined go with it, @page size and margins included. Our invoice also had a page-number footer in an @page margin box (@bottom-right { content: "Page " counter(page) " of " counter(pages) }). inline-css couldn't parse it and threw Error: Unexpected } (line 2, char 128), so there was no PDF at all.
2. Handlebars in strict mode. html-pdf-node compiles your HTML as a Handlebars template, with your HTML passed in as the data. Any double braces in it make the call fail. <p>Hello {{name}}</p> threw Cannot use 'in' operator to search for 'name' in <p>Hello {{name}}</p>. Double braces turn up in code samples and in Vue or Angular markup.
3. A new browser per call. generatePdf launches Chrome with --no-sandbox before steps 1 and 2, prints one PDF and closes the browser, but only when everything worked. When a step throws, Chrome stays open. Three failed requests to our Express handler left three Chrome processes running.
Its last release, 1.0.8, came out on December 14, 2021, and it still gets about 50,000 downloads a week. It pins Puppeteer ^10.4.0 from September 2021, which installs Chromium 93. Installing it today prints npm's own warning, [email protected]: < 24.15.0 is no longer supported, and npm audit reports 14 high-severity vulnerabilities. On an Apple Silicon Mac without Rosetta, the Chromium it downloads is an x86_64 build that won't start (spawn Unknown system error -86), so we pointed it at a current Chrome 157. The three failures described above come from html-pdf-node's own code, not from the old browser.
The older html-pdf package (PhantomJS) isn't a fix either. It's deprecated on npm, and its notice reads "Please migrate your projects to a newer library like puppeteer".
How do you replace html-pdf-node with Puppeteer?
Say your app has this Express route. It takes HTML in a POST and returns a PDF:
// Before: html-pdf-node
const express = require('express');
const html_to_pdf = require('html-pdf-node');
const app = express();
app.use(express.text({ type: 'text/html', limit: '5mb' }));
app.post('/invoice.pdf', async (req, res) => {
const pdf = await html_to_pdf.generatePdf(
{ content: req.body },
{ format: 'A4', printBackground: true }
);
res.type('application/pdf').send(pdf);
});
app.listen(3001);
And here's the same route with Puppeteer. The browser starts once, when the server starts, and each request gets its own page:
// After: Puppeteer, one browser for the whole process
import express from 'express';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const app = express();
app.use(express.text({ type: 'text/html', limit: '5mb' }));
app.post('/invoice.pdf', async (req, res) => {
const page = await browser.newPage();
try {
await page.setContent(req.body, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
});
res.type('application/pdf').send(Buffer.from(pdf));
} finally {
await page.close();
}
});
app.listen(3002);
The options carry over, because html-pdf-node passes them straight to Puppeteer's page.pdf(). Keep printBackground: true (the default is false) and set format, because Puppeteer defaults to Letter. Add preferCSSPageSize: true so an @page size in your CSS wins over format.
We posted the same invoice to both handlers. html-pdf-node answered 500. Puppeteer answered 200 with a 2-page A4 PDF: "Page 1 of 2" and "Page 2 of 2" in the footer, the table's header row repeated at the top of page 2, and the invoice's one line of JavaScript had run. To get any PDF out of html-pdf-node, we had to delete the page-number rule. Here's what both handlers printed from that cut-down invoice:
html-pdf-node took 2.2 s per request on that cut-down invoice, browser launch included. The Puppeteer handler printed the same cut-down invoice in 1.1 s with its browser already open. Single requests on one Mac: rough numbers, not a benchmark.
Prefer Playwright? It's the same route with chromium.launch() and page.pdf(). It printed the same 2 pages in our script. For memory limits, crashed browsers and timeouts under load, see our guides to Puppeteer PDF in production and Playwright PDF in production.
What does Puppeteer install, and why pin it?
npm i puppeteer also downloads its own browsers: Chrome for Testing and chrome-headless-shell. For Puppeteer 25.17.0 that was Chrome for Testing 157 (390 MB on our Mac) and chrome-headless-shell (210 MB), and they go in every image you ship. npm 11 now prints a warning that Puppeteer's install script isn't in your allowScripts list. It still ran and downloaded Chrome for us, but with strict-allow-scripts turned on, npm fails the install instead.
Each Puppeteer release downloads one specific Chrome version, so a version bump in your lockfile is also a browser upgrade. Lock the puppeteer version and update it on purpose. One developer on r/node got hit by exactly this: "we didn't lock down our chrome version and after a chrome release everything we printed in the factory was shrunk in scale by like 20%."
Can the same handler call an API instead of running Chrome?
Yes. Keep the route and replace the browser with one request to PDFShift. fetch has been built into Node since version 18, so there's nothing to install:
import express from 'express';
const app = express();
app.use(express.text({ type: 'text/html', limit: '5mb' }));
app.post('/invoice.pdf', async (req, res) => {
const response = await fetch('https://api.pdfshift.io/v3/convert/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.PDFSHIFT_API_KEY,
},
body: JSON.stringify({ source: req.body, format: 'A4', use_print: true }),
});
if (!response.ok) {
return res.status(response.status).send(await response.text());
}
res.type('application/pdf').send(Buffer.from(await response.arrayBuffer()));
});
app.listen(3003);
use_print: true renders your HTML with its print CSS, the same media Puppeteer's page.pdf() uses. We posted the full invoice, page-number rule included, and got back the same 2 pages Puppeteer printed. Each request took 1.5 to 1.9 s, network included. That's the whole setup: no Chrome in your install or your image, no browser to keep alive, and a failed request leaves nothing running.
Requests without a key work too, watermarked and up to 10 a day. Create a free account to get 50 credits a month, no card needed. Every parameter is in the API docs, and our Node guides show the same call with axios, got and node-fetch.
What about jsPDF, PDFKit, pdfmake and html2pdf.js?
None of them print the HTML and CSS you render on your server the way a browser does. PDFKit and pdfmake build a PDF from code: you place the text, tables and lines yourself. jsPDF's .html() depends on html2canvas, and html2pdf.js calls itself "Client-side HTML-to-PDF rendering". They fit a receipt you lay out in code, or a download button in the browser. For an invoice you already have as HTML, use one of the handlers above. If your app is in React, our React to PDF guide compares those routes.
We hope this guide was helpful. If one of these snippets doesn't work on your setup,
feel free to drop us a line.