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.
| Option | Pick 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).
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>
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:
printBackground: true is off by default, so background colors and images vanish without it.font-size inline, and they only show if the top/bottom margin leaves room. Use an empty headerTemplate or Chrome prints the date and title there. Images in templates must be data URIs.@page { size } in CSS only wins over format with preferCSSPageSize: true.waitUntil: "networkidle0", await page.waitForSelector("canvas") or await page.evaluateHandle("document.fonts.ready").args: ["--no-sandbox"] and install fonts (fonts-noto-core, fonts-noto-cjk); empty boxes in the PDF mean a missing font. "Could not find Chrome" means the postinstall download was skipped: run npx puppeteer browsers install chrome.Screenshots too? See Puppeteer screenshots: full page, element & PDF.
npm i playwright
npx playwright install chromiumimport { 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.
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.
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.
| Feature | Support |
|---|---|
| Rendering | All three: Chromium, flexbox, grid, JavaScript, web fonts |
| Page numbers | Puppeteer and Playwright: footerTemplate or CSS API: CSS @page margin boxes |
| Install | API: nothing (built-in fetch) Puppeteer and Playwright: plus a ~150 MB Chromium and system libraries |
| Serverless | API: any function Self-hosted: needs a slim Chromium build; see the Lambda and Vercel guide |
| Private pages | Puppeteer and Playwright: can reach localhost and your VPN API: public URLs or HTML you send |
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.
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