HTML to PDF in Node.js: the 2026 guide

26 July 2026

The fastest reliable way to convert HTML to PDF in Node.js is Puppeteer: install it with npm install puppeteer, load your HTML with page.setContent(), and call page.pdf(). That's a real headless-Chrome render in about ten lines, and the complete script is below. The decision gets harder after the first render: some of the most-downloaded HTML-to-PDF packages on npm have been dead for years, and running Chrome in production has costs the quickstarts never mention.

Here's the scenario. A ticket lands on your board: "Users need to download invoices as PDFs." You search npm for "html to pdf", and the results are a minefield of deprecated PhantomJS wrappers, browser-only libraries, and five-year-old Puppeteer forks that still pull six figures of downloads a week.

This guide sorts it out. You'll get a runnable Puppeteer script, a comparison of every Node.js HTML-to-PDF package worth considering in 2026 (with live download and maintenance data), the packages to avoid, the CSS that makes documents look right, and an honest look at what self-hosting Chrome costs in production. This page covers server-side Node.js; for jsPDF and html2pdf.js in the browser, see our JavaScript HTML-to-PDF guide.

Key TakeawaysPuppeteer's page.pdf() is the default answer for HTML to PDF in Node.js: full CSS and JavaScript support, ~10 lines of code, 11.4 million weekly downloads, actively maintained (v25 as of July 2026).Two of npm's most-downloaded PDF packages are dead. html-pdf (deprecated, PhantomJS-based) still gets 135,000 downloads a week; html-pdf-node hasn't shipped since 2021 and pins an ancient Chromium. Don't use either.Use PDFKit or pdfmake only when you don't have HTML. They build PDFs programmatically and never parse your markup or CSS.Fidelity is a CSS problem, not a library problem: break-inside: avoid fixes most page-break pain, and late-loading webfonts are the usual cause of blank or broken output.Production is where DIY gets expensive. Chromium doesn't fit standard Lambda limits without @sparticuz/chromium, leaks memory under load, and needs concurrency pooling. That's the point where a rendering API is worth the per-document cent.

Convert HTML to PDF in Node.js with Puppeteer

Puppeteer is Google-maintained browser automation for Node.js. It downloads a matching Chromium build on install, so your HTML renders with the same engine your users' browsers run. If it looks right in Chrome, it converts right.

The minimal working script

import puppeteer from "puppeteer";

const html = `<!doctype html>
<html>
  <body>
    <h1>Invoice #1042</h1>
    <p>Total due: $1,280.00</p>
  </body>
</html>`;

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
await page.pdf({ path: "invoice.pdf", format: "A4", printBackground: true });
await browser.close();

Run it with node invoice.mjs and invoice.pdf lands in your working directory. Two parameters matter more than the rest. waitUntil: "networkidle0" tells Puppeteer to wait until network requests stop before capturing, so stylesheets and images actually arrive. printBackground: true is the most-skipped option in the page.pdf() API, and it's the one that silently strips background colors from every styled invoice.

Prefer to see the end state first? The same render as a single HTTP request is in the API section below, or you can skim the Transformy HTML to PDF API docs and come back.

Rendering a URL instead of an HTML string

Pointing Puppeteer at a live page works the same way; the wait strategy is what changes. For client-rendered apps (React, Vue), networkidle0 can fire before your data paints, so wait for a selector that only exists when the page is done:

await page.goto("https://app.example.com/reports/42", {
  waitUntil: "networkidle0",
});
await page.waitForSelector(".report-total"); // rendered only after data loads
await page.pdf({ path: "report.pdf", format: "A4", printBackground: true });

The anti-pattern is a fixed setTimeout sleep. It's either too short (broken PDFs under load) or too long (slow renders all the time). Wait for evidence, not for a guess.

Rendering from a React codebase specifically? Our React HTML-to-PDF guide covers the component-side options too.

Page setup that matters

Real documents need margins, page numbers, and predictable sizing:

await page.pdf({
  path: "report.pdf",
  format: "A4",
  margin: { top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" },
  displayHeaderFooter: true,
  footerTemplate: `<div style="font-size:9px; width:100%; text-align:center;">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span>
  </div>`,
  headerTemplate: `<div></div>`,
});
Gotcha: header and footer templates render in an isolated context. Your page's stylesheets don't apply there, so every style must be inline, and fonts fall back to system defaults.

The best HTML to PDF npm packages compared (2026)

Searching npm for an HTML to PDF package returns a mix of active projects, browser-only tools, and abandoned wrappers. Here's the current field, with registry data pulled July 26, 2026:

Package Approach HTML/CSS fidelity Status (July 2026) Weekly downloads
puppeteer Headless Chrome Full (real browser) Active, v25.3.0 11.4M
playwright Headless Chromium Full (real browser) Active, v1.62.0 72.6M
pdfkit Programmatic drawing None (no HTML input) Active, v0.19.1 5.4M
pdfmake Declarative doc definition None (no HTML input) Active, v0.3.11 2.2M
jspdf Browser-first generation Partial (rasterized) Active, v4.2.1 13.2M
html-pdf PhantomJS (2016-era WebKit) Poor by modern standards Deprecated 135K
html-pdf-node Wrapper on old Puppeteer Full, but frozen engine Unmaintained since 2021 77K

Every Node HTML to PDF library in the table falls into one of two families. Browser-based packages (Puppeteer, Playwright) render your actual HTML and CSS. Programmatic packages (PDFKit, pdfmake) build PDFs from drawing commands or document definitions and never see your markup. jsPDF is a browser library first; on a Node server it's the wrong tool for HTML fidelity.

Puppeteer vs Playwright for PDF generation

For PDFs specifically, they're near-identical: both drive Chromium, and Playwright's page.pdf() works only in Chromium anyway (not Firefox or WebKit). Playwright's 72.6 million weekly downloads reflect its dominance in end-to-end testing, not PDF work.

The practical rule: if your project already runs Playwright for tests, reuse it and read our Playwright HTML-to-PDF guide. Otherwise pick Puppeteer for the larger PDF-specific ecosystem; our Puppeteer HTML to PDF walkthrough covers the full options surface. Don't add both to one codebase for the same job; they bundle separate ~170 MB browser builds.

PDFKit and pdfmake: PDFs without a browser

PDFKit gives you a drawing API: doc.text(), doc.image(), coordinates, and streams. pdfmake wraps a similar engine in a declarative JSON document definition. Both are fast and light because they skip the browser entirely.

That's also the limitation: neither converts HTML. If your invoice already exists as an HTML template, you'd be rebuilding it line by line in another format. Reach for them when your PDF is generated data with no HTML heritage; our PDFKit guide walks through where that line sits.

npm packages to avoid in 2026

Two HTML to PDF npm packages deserve an explicit warning, because the download charts make them look safe.

html-pdf is officially deprecated; its own install banner says to migrate to Puppeteer. It renders with PhantomJS, whose development was suspended in March 2018. That means a 2016-era WebKit: no CSS grid, shaky flexbox, no modern JavaScript. It still gets 135,000 downloads every week, mostly from legacy lock files and copy-pasted tutorials.

html-pdf-node wraps Puppeteer, which sounds fine until you check the date: last published five years ago, pinning a Chromium from that era. You inherit every rendering bug and security patch gap since. It still moves 77,000 downloads a week.

Here's how that typically plays out. A developer, call her Priya, inherits an invoicing service built on html-pdf back in 2019. A redesigned invoice template uses CSS grid; PhantomJS silently collapses the layout, and a few thousand customer invoices go out with overlapping line items before support tickets surface it. The fix is not a patch. It's a migration to headless Chrome, done under incident pressure instead of on the team's own schedule.

The migration path from either package is the same: swap in the Puppeteer script from the top of this guide, or hand the render to an API and delete the browser dependency entirely. If you're leaving a self-hosted browser stack for other reasons too, our Puppeteer alternatives comparison covers the options.

Making PDFs look right: page breaks, fonts, headers

Getting a PDF out of Node.js takes ten lines. Getting the right PDF is CSS work, and the same three issues cause most of the pain when you convert HTML to PDF in Node, whichever Chromium-based tool you use.

Page breaks that don't split tables

Chromium honors CSS fragmentation rules when printing. The one property that removes most page-break pain is break-inside: avoid:

tr, .line-item, .signature-block {
  break-inside: avoid;
}
h2 {
  break-after: avoid; /* keep headings attached to their content */
}

Set it on table rows and any block that must stay whole. For full control, @page rules define page size and margins in CSS itself, which keeps layout decisions in the template instead of the render call:

@page {
  size: A4;
  margin: 20mm 15mm;
}
@page :first {
  margin-top: 30mm; /* room for a letterhead on page one */
}

With Puppeteer, pass preferCSSPageSize: true to page.pdf() so these rules win over the call's own format option. The Transformy API exposes the same switch as prefer_css_page_size.

Webfonts and images that load late

The classic blank-PDF bug: the render captures before your webfont or hero image arrives, so text falls back or vanishes. networkidle0 catches most cases, but fonts served from a slow CDN can still lose the race.

The robust fix is to stop trusting the network during a render. Self-host fonts next to your template, or inline them as base64 @font-face data URIs. For belt and suspenders, wait on the browser's own signal: await page.evaluateHandle("document.fonts.ready") before calling page.pdf().

Headers, footers, and page numbers

Repeating headers with page numbers come from displayHeaderFooter plus the template snippets shown earlier. Remember the isolation gotcha: inline styles only, and keep templates small. If you need the current date or document title, Chromium injects them into elements with the classes date and title, the same way pageNumber and totalPages work.

Want to check your own template's output? Transformy's test keys render real, watermarked PDFs for free, with no quota and no credit card, so you can iterate on page breaks against the same headless Chrome you'd ship with.

Running Puppeteer in production: where it gets expensive

Everything above works on your laptop. Production is a different sport for a Node.js HTML to PDF service, and it's where the honest build-vs-buy math happens.

Serverless: Lambda and Vercel need a special build

Stock Puppeteer doesn't deploy to AWS Lambda: the bundled Chromium alone overflows Lambda's 250 MB unzipped limit. The community answer is @sparticuz/chromium, a trimmed Chromium build paired with puppeteer-core. It works, with tradeoffs: cold starts stretch to 5 to 15 seconds while the binary unpacks, memory needs jump to 1 GB or more, and you now track three version numbers (Lambda runtime, chromium build, puppeteer-core) that must stay compatible.

The version matrix is the part that bites in practice. Picture a solo founder, Marco, whose Vercel function renders receipts fine for months, until a routine puppeteer-core minor bump breaks the pairing with his pinned chromium build. Renders fail for two days while he bisects versions, because the error surfaces as a generic launch timeout, not a version mismatch.

Memory, zombies, and concurrency

Long-running browser processes leak. Under sustained load, Chromium instances grow, crash, or worse, orphan themselves: the zombie processes that show up as mystery memory pressure at 2 a.m. Production setups end up with a browser pool (launch N instances, recycle each after M renders), queue backpressure, and health checks that kill hung renders. None of it is exotic, but all of it is infrastructure you now own, patch, and page yourself about.

When a library is enough, and when it isn't

Honest framing: if you render a handful of PDFs a day from templates you control, self-hosted Puppeteer is free and fine. Run the script from this guide and ship.

The equation flips when rendering stops being your product and starts being your pager: spiky volume (end-of-month invoice runs), SPAs that need JS to settle, serverless deploys, or fidelity bugs you keep re-fixing. At that point you're maintaining a browser farm to avoid a $0.01-per-document line item.

Using an HTML to PDF API from Node.js

An HTML to PDF API moves the browser to someone else's infrastructure. From Node.js it's one fetch call, with no Chromium in your dependency tree and no cold-start tax:

import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    html: invoiceHtml,
    page_size: "A4",
    margin: "20mm 15mm",
    footer: {
      html: '<span style="font-size:9px">Page {{page}} of {{pages}}</span>',
    },
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("invoice.pdf", Buffer.from(await res.arrayBuffer()));

The render happens on real headless Chromium, so everything in the fidelity section above (page breaks, @page, webfonts) behaves identically. What moves off your plate is the operational layer: scaling, browser patching, memory management, and the version matrix. Transformy's free tier covers 100 documents a month with no credit card, which is enough to ship the feature and find out whether your volume ever makes the math interesting.

FAQ

How do I convert HTML to PDF in Node.js?

Install Puppeteer (npm install puppeteer), load your markup with page.setContent(), and call page.pdf(). The ten-line script at the top of this guide is complete and runnable. For production volume, a rendering API does the same job as one HTTP POST.

What is the best Node HTML to PDF library?

Puppeteer is the best HTML to PDF npm package for most projects in 2026: full CSS and JavaScript fidelity, active maintenance, 11.4 million weekly downloads. Use Playwright if you already run it for testing, and PDFKit or pdfmake only when your document doesn't start as HTML.

Is html-pdf deprecated? What should I use instead?

Yes. html-pdf is officially deprecated and renders with PhantomJS, which stopped development in 2018. Migrate to Puppeteer for a self-hosted setup, or to an HTML to PDF API if you'd rather not run a browser at all. Avoid html-pdf-node too; it hasn't been updated in five years.

How do I convert HTML to PDF in Node.js without Puppeteer?

Three routes: Playwright (same Chromium engine, different tooling), PDFKit or pdfmake (programmatic PDFs, no HTML parsing), or a hosted API where you POST HTML and receive the PDF bytes back. Which one fits depends on whether you need HTML fidelity and whether you want to operate a browser.

How do I generate a PDF from a URL in Node.js?

With Puppeteer, use page.goto(url, { waitUntil: "networkidle0" }), wait for a selector that proves the page finished rendering, then call page.pdf(). With the Transformy API, send { "url": "..." } instead of an html field; custom headers, cookies, and basic auth parameters handle pages that sit behind a login.

Can I run Puppeteer on AWS Lambda or Vercel?

Yes, with @sparticuz/chromium plus puppeteer-core, because stock Chromium exceeds Lambda's 250 MB limit. Budget for 5 to 15 second cold starts, at least 1 GB of memory, and keeping the chromium/puppeteer-core version pairing in sync.

How do I add page numbers to a PDF in Node.js?

With Puppeteer, set displayHeaderFooter: true and a footerTemplate using the pageNumber and totalPages classes. With the Transformy API, pass a footer.html snippet with {{page}} and {{pages}} tokens. Either way, styles must be inline because templates render in isolation.

Conclusion

Converting HTML to PDF in Node.js comes down to picking the right layer, not the cleverest library:

  • Quick script or low volume: Puppeteer's page.pdf(), with printBackground: true and a real wait strategy.
  • Already testing with Playwright: reuse it; the PDF surface is the same Chromium.
  • No HTML source: PDFKit or pdfmake, drawn programmatically.
  • Never: html-pdf or html-pdf-node, whatever the download counts say.
  • Production scale, serverless, or SPAs: hand the browser to an API and keep the ten lines of business logic.

Whichever route you take, the fidelity work stays with your CSS: break-inside: avoid on things that must not split, self-hosted fonts, inline-styled footers.

If you'd rather find out today whether the API route fits, render your first PDF on Transformy's free tier: 100 documents a month, test keys that render real watermarked output, no credit card. Paste the fetch example above and you're done before your coffee cools.