HTML to PDF in Express.js

How to turn an EJS template into a PDF in an Express app, tested on Express 5.3.0 and Express 4.22.3 with Node 22: Puppeteer in the same process, or a call to an HTML-to-PDF API. We started from the version most tutorials show, measured what goes wrong (a missing logo, 2.6 GB of RAM for 20 requests, and PDFs sent as JSON on Express 4), and fixed each problem.

Quick answer

OptionPick it when
Puppeteer, one shared browser
Chrome inside your Node process
You control the server and can give it ~300 MB of Chrome plus RAM per page. Reuse one browser, cap concurrent pages, and load the template by URL.
fetch() to an API
Chrome on someone else's server
Serverless, small containers, or hosts where Chrome won't install. No browser to keep alive; inline local images first.
PDFKit / pdfmake
Draw the PDF in code
You don't have HTML at all and are happy to place text by coordinates. Not covered here: no CSS.

The EJS template

views/invoice.ejs: a flexbox header with a logo from public/logo.png, a price list and a ✓. <%= %> escapes HTML, so customer names can't inject scripts into the browser that prints them; never switch those to <%- %>.

<!doctype html>
<html><head><meta charset="utf-8"><title>Invoice <%= number %></title><style>
  body { font-family: sans-serif; }
  .top { display: flex; justify-content: space-between; align-items: center;
         background: #eef2ff; padding: 12px; }
  table { width: 100%; border-collapse: collapse; }
  td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
  <div class="top">
    <img src="/logo.png" width="160" height="48" alt="ACME">
    <h1>Invoice #<%= number %></h1><p>Due: <%= due %></p>
  </div>
  <table><tr><th>Item</th><th>Price</th></tr>
    <% items.forEach(item => { %>
      <tr><td><%= item.name %></td><td>€<%= item.price.toFixed(2) %></td></tr>
    <% }) %>
  </table>
  <p>✓ Paid by card</p>
</body></html>

The version most tutorials show

npm i express ejs puppeteer, then a route that renders the template, launches Chrome, prints and closes it:

import express from "express";
import puppeteer from "puppeteer";

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));

const invoice = {
  number: 1042, due: "2026-11-01",
  items: [{ name: "Consulting (3h)", price: 450 }, { name: "Hosting, Oct.", price: 19 }],
};

app.get("/invoice.pdf", async (req, res) => {
  const html = await new Promise((ok, fail) =>
    app.render("invoice", invoice, (err, out) => (err ? fail(err) : ok(out))));
  const browser = await puppeteer.launch({ args: ["--no-sandbox"] });
  const page = await browser.newPage();
  await page.setContent(html);
  const pdf = await page.pdf({ format: "A4" });
  await browser.close();
  res.contentType("application/pdf");
  res.send(pdf);
});

app.listen(3100);

It returns a valid one-page PDF in 0.45 s, and it has three problems we measured:

A trap in the obvious logo fix

The usual advice is to page.goto() your own site first, then call setContent(), so relative URLs resolve. We tried it with goto("http://127.0.0.1:3100/") and the logo was still missing. That path had no route, and Express 5's 404 page sends Content-Security-Policy: default-src 'none'; the policy stays on the page after setContent(), and Chrome blocked the image (requestfailed … csp). Rather than going to some page first, serve the invoice as HTML on its own route and let Chrome load that URL. You also get a preview you can open in your own browser while you design the template.

1. Puppeteer done right

One browser for the whole process, at most 3 pages at once, the template loaded by URL, and errors passed to Express:

import express from "express";
import puppeteer from "puppeteer";

const PORT = 3100;
const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));

const invoice = {
  number: 1042, due: "2026-11-01",
  items: [{ name: "Consulting (3h)", price: 450 }, { name: "Hosting, Oct.", price: 19 }],
};

// One browser for the whole process, not one per request
const browser = await puppeteer.launch({ args: ["--no-sandbox"] });

// At most 3 PDFs at once; the rest wait their turn
let running = 0;
const queue = [];
async function limit(fn) {
  if (running >= 3) await new Promise(ok => queue.push(ok));
  running++;
  try { return await fn(); } finally { running--; queue.shift()?.(); }
}

// The HTML version doubles as a preview in your own browser
app.get("/invoice", (req, res) => res.render("invoice", invoice));

app.get("/invoice.pdf", async (req, res, next) => {
  try {
    const pdf = await limit(async () => {
      const page = await browser.newPage();
      try {
        // Load the real URL, not setContent(): on about:blank, /logo.png has no host
        await page.goto(`http://127.0.0.1:${PORT}/invoice`, { waitUntil: "load" });
        return await page.pdf({ format: "A4", printBackground: true });
      } finally {
        await page.close();
      }
    });
    res.type("pdf");
    res.attachment("invoice-1042.pdf");
    res.send(Buffer.from(pdf)); // Express 4 sends a bare Uint8Array as JSON
  } catch (err) {
    next(err);
  }
});

app.listen(PORT);

Run it with node server.mjs (it uses top-level await, so keep the .mjs extension or set "type": "module"). Results on the same box: the 160×48 logo is in the PDF, the title is "Invoice 1042" from the template's <title>, one PDF takes 0.13 s once the browser is warm, and 20 at once finished in 1.7 s with 0.2 GB of extra RAM instead of 2.6 GB.

2. An API call instead of Chrome

No browser in your app: render the template to a string and POST it. The renderer can't reach your localhost, so local images go in as data URIs (images on a public URL can stay as they are, or use base_url with your public origin). Set SHOTMINT_KEY to a key from the free tier (25 PDFs a month):

import express from "express";
import { readFile } from "node:fs/promises";

const app = express();
app.set("view engine", "ejs");

const invoice = {
  number: 1042, due: "2026-11-01",
  items: [{ name: "Consulting (3h)", price: 450 }, { name: "Hosting, Oct.", price: 19 }],
};

// The renderer can't reach localhost: inline local images as data URIs
const logo = "data:image/png;base64," +
  (await readFile("public/logo.png")).toString("base64");

app.get("/invoice.pdf", async (req, res, next) => {
  try {
    let html = await new Promise((ok, fail) =>
      app.render("invoice", invoice, (err, out) => (err ? fail(err) : ok(out))));
    html = html.replace('src="/logo.png"', `src="${logo}"`);

    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: "15mm" }),
      signal: AbortSignal.timeout(60_000),
    });
    if (!r.ok) throw new Error(`PDF failed: ${r.status} ${await r.text()}`);

    res.type("pdf");
    res.attachment("invoice-1042.pdf");
    res.send(Buffer.from(await r.arrayBuffer()));
  } catch (err) {
    next(err);
  }
});

app.listen(3100);

Tested with SHOTMINT_KEY=… node server.mjs: an A4 PDF with the 160×48 logo, € prices and ✓, in 0.1 to 0.14 s from a server in the same data centre (add your own network round trip), and 20 parallel requests all returned 200 in 1.2 s. It uses Node's built-in fetch, so there are no new dependencies; print_background is on by default, and options such as page_numbers, header_template and landscape are listed in the API reference.

Side by side

What we measuredResult
Naive Puppeteer (browser per request)0.45 s each, logo missing
20 at once: 6.25 s, +2.6 GB RAM
Shared browser, 3 pages max0.13 s each, logo present
20 at once: 1.7 s, +0.2 GB RAM, ~300 MB Chrome install
API call0.1 to 0.14 s each, logo inlined
20 at once: 1.2 s, nothing installed; 1 credit per PDF

Which one?

If you already run Express on a VM or a container with a gigabyte of RAM to spare, the shared-browser Puppeteer version is free and fast once you apply the three fixes. If you deploy to serverless functions, small containers or a host where installing Chrome is a fight, use the API. To see how your HTML prints in Chrome before writing any code, paste it into the free HTML to PDF converter. More on the Node side: HTML to PDF in Node.js (libraries compared), Puppeteer PDF options, page numbers, React components to PDF.

Try it with 25 free renders a month

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). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons