HTML to PDF in Node.js
Almost every Node answer to this ends at Puppeteer, and Puppeteer works. The cost
is that your application now ships a browser: a few hundred megabytes in the image,
a --no-sandbox decision you have to make and justify, libnss3 and friends to
install on slim base images, and a process tree to supervise so a crashed tab does
not leak until the pod restarts.
If you need a browser anyway — scraping, screenshots, end-to-end tests — keep Puppeteer. If you only need documents, you are operating a browser to print an invoice.
One-off render
No dependencies. fetch has been in Node's core since 18.
import { writeFile } from "node:fs/promises";
const html = `<!doctype html>
<html><head><style>
@page { size: A4; margin: 20mm; }
body { font: 14px/1.6 system-ui, sans-serif; color: #16181d; }
h1 { font-size: 28px; margin: 0 0 4px; }
</style></head>
<body>
<h1>Invoice 2026-0142</h1>
<p>Issued 2026-08-08 · Due 2026-09-07</p>
</body></html>`;
const res = await fetch("https://renderpaper.com/v1/render", {
method: "POST",
headers: {
"X-API-Key": process.env.RENDER_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ template: html }),
});
if (!res.ok) {
throw new Error(`render failed: ${res.status} ${await res.text()}`);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
console.log("wrote invoice.pdf");
RENDER_API_KEY=rs_live_… node invoice.mjs
Stored templates: send data, not markup
The version above puts the document inside your Node code, which means a designer asking for a wider margin is a pull request and a deploy. Store the template once and send only data.
import { writeFile } from "node:fs/promises";
const TEMPLATE_ID = "your-template-id";
const res = await fetch(
`https://renderpaper.com/v1/templates/${TEMPLATE_ID}/render`,
{
method: "POST",
headers: {
"X-API-Key": process.env.RENDER_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
InvoiceNumber: "2026-0142",
Customer: { Name: "Northwind Trading AB" },
LineItems: [
{ Description: "Consulting", Quantity: 12, Amount: 14400 },
{ Description: "Hosting", Quantity: 1, Amount: 4350 },
],
Total: 18750,
},
}),
},
);
if (!res.ok) {
throw new Error(`render failed: ${res.status} ${await res.text()}`);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
The template itself is a Go html/template, which for this purpose reads like any
other mustache-ish syntax:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Streaming it from Express
Pipe the response body straight to the client — no temp file, no buffering the whole document in memory:
import { Readable } from "node:stream";
app.get("/invoices/:id.pdf", async (req, res, next) => {
try {
const upstream = await fetch(
`https://renderpaper.com/v1/templates/${process.env.TEMPLATE_ID}/render`,
{
method: "POST",
headers: {
"X-API-Key": process.env.RENDER_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ data: await invoiceData(req.params.id) }),
},
);
if (!upstream.ok) {
return res.status(502).send("render failed");
}
res.type("application/pdf");
res.setHeader("Content-Disposition", 'attachment; filename="invoice.pdf"');
Readable.fromWeb(upstream.body).pipe(res);
} catch (err) {
next(err);
}
});
Why not Puppeteer
Nothing here is a knock on the library. The tradeoff is operational:
- Image size. A Node service that would fit in ~150MB becomes ~500MB or more once Chromium is inside it, and every base-image bump is a new set of shared libraries to chase.
- Sandboxing. In most containers Puppeteer needs
--no-sandboxto start, which means you have removed a security boundary to print a document. - Concurrency is yours. Each render is a browser tab with real memory cost. You decide the cap, the queue and what request 51 gets told.
- Lifecycle is yours. Browsers crash. Reaping them, bounding their memory, and keeping one pathological document from taking the process down all become application concerns.
Self-host when documents must not leave your network, or when you need a browser for other reasons. Otherwise this is a lot of surface for a PDF.
Why not html-pdf or wkhtmltopdf wrappers
html-pdf and the various wkhtmltopdf bindings are lighter, and for a plain
document they still work. The limit is the renderer: wkhtmltopdf's engine predates
flexbox and grid, so a modern layout silently collapses into something that is not
what your designer built. It has been unmaintained for years.
If your document is simple and unchanging, that may be fine. If a person with opinions about typography is involved, it will not be.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, no account needed. The free
tier is 50 documents a month with no card.