Guide
Generate a PDF from HTML in Node.js
You have an Express route, some HTML (an invoice, a report, a receipt), and you need to hand the user a PDF. The usual answer is to bundle Puppeteer and drive a headless Chromium. This guide shows the lighter path: a single fetch() call to a rendering API, with the PDF piped straight back to the client.
The minimal Express route
The PDFPipe endpoint is POST https://api.pdfpipe.xyz/v1/pdf. Send a JSON body with your HTML and options, attach a bearer token, and you get back raw application/pdf bytes. Here is a complete route that builds an invoice and streams the result to the browser.
import express from "express";
const app = express();
app.use(express.json());
const PDFPIPE_KEY = process.env.PDFPIPE_KEY; // pp_live_your_key
app.get("/invoice/:id", async (req, res) => {
const html = `
<html>
<body style="font-family: sans-serif; padding: 40px">
<h1>Invoice #${req.params.id}</h1>
<p>Amount due: $420.00</p>
</body>
</html>
`;
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PDFPIPE_KEY}`,
},
body: JSON.stringify({
html,
options: { format: "A4" },
}),
});
if (!upstream.ok) {
const detail = await upstream.text();
return res.status(502).json({ error: "PDF render failed", detail });
}
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`inline; filename="invoice-${req.params.id}.pdf"`
);
// Stream the bytes straight to the client.
const buffer = Buffer.from(await upstream.arrayBuffer());
res.send(buffer);
});
app.listen(3000);That is the whole feature. No browser launch, no temp files, no --no-sandbox flags. The fetch() call is built into Node 18 and later, so there is nothing to install.
Streaming without buffering
For large documents you can avoid holding the full PDF in memory by piping the response body directly. The web stream that fetch() returns can be bridged to the Express response.
import { Readable } from "node:stream";
// inside the route, after the upstream.ok check:
res.setHeader("Content-Type", "application/pdf");
Readable.fromWeb(upstream.body).pipe(res);Why not just bundle Puppeteer?
Puppeteer is a great library and the right tool when you need full local control: complex interactions, screenshots, scraping, or rendering you cannot send off your network. But for the common case of turning HTML into a PDF inside a web service, shipping a headless browser carries real cost.
| Concern | Bundled Puppeteer | fetch to PDFPipe |
|---|---|---|
| Install size | Chromium adds roughly 280 MB to the image | Zero, fetch ships with Node |
| Memory per render | A browser tab can spike to hundreds of MB | A single HTTP request |
| Cold starts | Launching Chromium adds seconds on serverless | No launch step, just a network round trip |
| Serverless fit | Often exceeds size and memory limits | Runs on the smallest function tier |
| Ops burden | System fonts, sandbox flags, zombie processes | None, the rendering is managed |
The headline issues are memory and cold starts. A headless Chromium process holds a lot of resident memory, and on platforms like AWS Lambda, Vercel, or Cloud Run the launch time is paid on every cold invocation. Offloading the render turns a heavyweight subprocess into an ordinary outbound request.
Handling errors and timeouts
Treat the render like any other upstream dependency. Check the status, surface a useful message, and add a timeout with AbortSignal so a slow render does not hang your request.
const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
},
body: JSON.stringify({ html, options: { format: "A4" } }),
signal: AbortSignal.timeout(15000), // fail after 15s
});
if (!upstream.ok) {
throw new Error(`render failed: ${upstream.status}`);
}Keep your API key in an environment variable, never in client code, since the route runs on the server. The options object also accepts other page sizes (for example Letter), margins, and landscape orientation, documented in full on the docs page.
Paste your own HTML into the live playground on the home page to see the PDF render instantly, then read the docs for every option.
See pricing →