Renderpaper
Log in Start free

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:

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.

Last updated 2026-08-08.