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.
| Option | Pick 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. |
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>
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:
setContent() loads the HTML into about:blank, where /logo.png has no host to resolve against.page.pdf() returns a Uint8Array in Puppeteer 22 and later. Express 5 sends it as bytes, but Express 4's res.send() only recognises Buffers, so it answered with {"0":37,"1":80,"2":68,"3":70} and a Content-Type: application/pdf; charset=utf-8 header. Wrap it in Buffer.from() and it works on both.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.
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.
--no-sandbox (or an AppArmor profile) to start at all; we kept it in the snippet. Only render your own templates with it, never arbitrary URLs from users.process.on("SIGTERM", () => process.exit(0)).browser.on("disconnected") and launch a new one.page.setCookie().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.
| What we measured | Result |
|---|---|
| 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 max | 0.13 s each, logo present 20 at once: 1.7 s, +0.2 GB RAM, ~300 MB Chrome install |
| API call | 0.1 to 0.14 s each, logo inlined 20 at once: 1.2 s, nothing installed; 1 credit per PDF |
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.
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).
All guides: guides · integrations · comparisons