HTML to PDF in Node.js
, 7 min read
There are three common ways to turn HTML into a PDF from Node.js: drive a headless browser yourself (Puppeteer or Playwright), use a PDF-drawing library (PDFKit, pdf-lib), or call an HTTP API. This guide shows the first and the last, and when each makes sense.
Option 1: Playwright on your own server
Playwright ships a Chromium build and a page.pdf() method. A minimal script:
// npm i playwright && npx playwright install chromium
import { chromium } from "playwright";
import { writeFile } from "node:fs/promises";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent("<h1>Invoice #1001</h1><p>Total: €120.00</p>", { waitUntil: "load" });
const pdf = await page.pdf({ format: "A4", printBackground: true, margin: { top: "20mm", bottom: "20mm" } });
await writeFile("invoice.pdf", pdf);
await browser.close();This works well on a laptop. In production you also need to handle:
- Memory and concurrency. Each Chromium page can take 50–150 MB. Reuse one browser, cap concurrent pages, and restart the browser if it crashes.
- Fonts. Slim Docker images have almost no fonts, so text falls back to something ugly. Install the fonts you use (and an emoji/CJK font if users can type anything).
- Untrusted input. If users control the HTML or a URL, the page can request
http://169.254.169.254/or your internal services from inside your network. You need to block private addresses for the page and every sub-resource. - Timeouts. A page that never finishes loading will hold a browser slot forever without a hard deadline.
Option 2: call an API
If you’d rather not run Chromium, send the HTML over HTTP. With pagenull it’s one fetch; Node 18+ has it built in:
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.pagenull.dev/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAGENULL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Invoice #1001</h1><p>Total: €120.00</p>",
format: "A4",
margin: { top: "20mm", bottom: "20mm" },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));Serving the PDF from Express
To send the PDF straight to a browser instead of saving it, stream the response through:
import express from "express";
import { Readable } from "node:stream";
const app = express();
app.get("/invoices/:id.pdf", async (req, res) => {
const html = await renderInvoiceHtml(req.params.id); // your own templating
const upstream = await fetch("https://api.pagenull.dev/v1/pdf", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAGENULL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ html, format: "A4" }),
});
if (!upstream.ok) return res.status(502).json(await upstream.json());
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="invoice-${req.params.id}.pdf"`);
Readable.fromWeb(upstream.body).pipe(res);
});
app.listen(3000);CSS that prints well
- Use
@page { size: A4; margin: 20mm }withpreferCssPageSize: trueif you’d rather set page size in CSS. break-inside: avoidon table rows and cards stops them being split across pages.thead { display: table-header-group }repeats a table header on every page (Chromium does this by default for<thead>).- Backgrounds only print with
printBackground: true(the pagenull default). - Prefer web fonts loaded with
<link>or@font-face; the renderer waits fordocument.fonts.readywhen JavaScript is on.
Which to pick
Run Playwright yourself if you render a few trusted documents and already operate containers. Use an API if the HTML comes from users, volume is spiky, or you’d rather not maintain a browser fleet. Either way, test with your real fonts and longest documents.