Skip to content

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:

render.mjs
// 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:

render.mjs
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:

server.mjs
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 } with preferCssPageSize: true if you’d rather set page size in CSS.
  • break-inside: avoid on 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 for document.fonts.ready when 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.