HTML to PDF in Node.js

Three ways to turn HTML into a real, text-selectable PDF from Node.js, all run on the same invoice template, plus the npm packages you should skip. Tested with Puppeteer 25, Playwright 1.64 and Node 22.

Quick answer

OptionPick it when
Puppeteer
Chrome DevTools, by the Chrome team
You already run Chrome in Node, or want the most examples online. Page numbers via headerTemplate / footerTemplate.
Playwright
Same Chromium print engine
You also write browser tests, or want built-in auto-waiting. Same PDF options as Puppeteer.
An API
Chromium on someone else's server
Serverless functions, small containers, or apps where a ~150 MB browser per instance isn't worth running.

Skip these: html-pdf and phantom-html2pdf run on PhantomJS, abandoned since 2018 (see the PhantomJS migration guide); wkhtmltopdf wrappers use a 2012-era WebKit (wkhtmltopdf alternative); pdfkit (the npm one) and pdf-lib draw PDFs from code and don't read HTML at all; jsPDF + html2canvas runs in the browser and usually produces a screenshot inside a PDF, so the text can't be selected or searched (html2canvas alternative).

The test template

Save this as invoice.html:

<!doctype html>
<html><head><meta charset="utf-8"><style>
  body { font-family: sans-serif; }
  .top { display: flex; justify-content: space-between; background: #eef2ff; padding: 12px; }
  table { width: 100%; border-collapse: collapse; }
  td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
  tr { break-inside: avoid; }
</style></head><body>
  <div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
  <table><tr><th>Item</th><th>Price</th></tr>
    <tr><td>Consulting (3h)</td><td>€450.00</td></tr>
    <tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>

1. Puppeteer

npm i puppeteer

Save as pdf.mjs and run node pdf.mjs:

import puppeteer from "puppeteer";
import { readFile } from "node:fs/promises";

const html = await readFile("invoice.html", "utf8");
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,
  margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
  displayHeaderFooter: true,
  headerTemplate: "<span></span>",
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:right;padding-right:15mm">' +
    'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
});
await browser.close();

Result: an A4 PDF with the blue header bar, title and due date side by side, selectable text and "Page 1 of 1" at the bottom right. For a live page, use await page.goto("https://example.com", { waitUntil: "networkidle0" }) instead of setContent. Things that trip people up:

Screenshots too? See Puppeteer screenshots: full page, element & PDF.

2. Playwright

npm i playwright
npx playwright install chromium
import { chromium } from "playwright";
import { readFile } from "node:fs/promises";

const html = await readFile("invoice.html", "utf8");
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.pdf({
  path: "invoice-pw.pdf",
  format: "A4",
  printBackground: true,
  margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
  displayHeaderFooter: true,
  headerTemplate: "<span></span>",
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:right;padding-right:15mm">' +
    'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
});
await browser.close();

The output is the same as Puppeteer's: both drive Chromium's print-to-PDF, so the options map one to one (note networkidle instead of networkidle0). PDF export only works in Chromium, not in Firefox or WebKit. More in the Playwright screenshot guide.

3. An API (no browser to install)

Shotmint renders in the same Chromium, but your Node process only makes an HTTP call: no browser binary, no fonts to install, nothing to keep alive. Uses the built-in fetch (Node 18+):

import { readFile, writeFile } from "node:fs/promises";

const html = await readFile("invoice.html", "utf8");
const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
  method: "POST",
  headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ html, format: "A4", margin: "20mm" }),
});
if (!r.ok) throw new Error(r.status + " " + (await r.text()));
await writeFile("invoice-api.pdf", Buffer.from(await r.arrayBuffer()));

For a live page, send url instead of html. Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), scale, print_background (on by default) and screen_media. There is no header/footer template option: for page numbers, use CSS @page { @bottom-right { content: "Page " counter(page) " of " counter(pages) } }, which current Chromium supports. Images and fonts must be reachable from the internet (or inlined as data URIs). More in the Node.js API guide and the invoice guide.

Serving the PDF from Express

Whichever renderer you use, send the buffer with the right headers instead of writing a file (here with Puppeteer and one shared browser):

import express from "express";
import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const app = express();

app.get("/invoice/:id.pdf", async (req, res) => {
  const page = await browser.newPage();
  try {
    await page.setContent("<h1>Invoice " + Number(req.params.id) + "</h1>");
    const pdf = await page.pdf({ format: "A4", printBackground: true });
    res.type("application/pdf")
       .set("Content-Disposition", 'inline; filename="invoice.pdf"')
       .send(Buffer.from(pdf));
  } finally {
    await page.close();
  }
});

app.listen(3000);

page.pdf() returns a Uint8Array in current Puppeteer; wrap it in Buffer.from or Express sends it as JSON. Never put user input into the HTML unescaped: anyone could inject scripts that run in your server's browser.

Side by side

FeatureSupport
RenderingAll three: Chromium, flexbox, grid, JavaScript, web fonts
Page numbersPuppeteer and Playwright: footerTemplate or CSS
API: CSS @page margin boxes
InstallAPI: nothing (built-in fetch)
Puppeteer and Playwright: plus a ~150 MB Chromium and system libraries
ServerlessAPI: any function
Self-hosted: needs a slim Chromium build; see the Lambda and Vercel guide
Private pagesPuppeteer and Playwright: can reach localhost and your VPN
API: public URLs or HTML you send

Which one?

If your app can run a browser, Puppeteer or Playwright are free and give you full control; pick the one your team already knows. If it can't (Lambda, Vercel, a small container) or you'd rather not keep Chromium patched and fed with fonts, use the API. Want to see the output first? Paste your HTML into the free HTML to PDF converter; no key needed. Python instead? See HTML to PDF in Python.

Try it with 100 free renders

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire).

Get a free API key Full API reference