Guide
Node.js PDF generation with a REST API
Generate PDFs in Node.js with fetch: a reusable helper with retries, and an Express route that streams a PDF to the browser. No Puppeteer, no Chromium.
Updated · Every code sample on this page is run against the API before publishing.
The approach
The usual way to make PDFs in Node.js is to render HTML in headless Chromium with Puppeteer or Playwright. That works, but it puts a browser in your deployment, with its memory use, cold starts and system dependencies. (The trade-offs are covered in PDF generation without Puppeteer.)
With a JSON to PDF API, your Node code builds a plain object and sends it with the built-in fetch (Node.js 18 and later). There is nothing to install for the basic case.
A reusable generatePdf helper
This module exports generatePdf(document), which returns the PDF as a Buffer. It retries rate limits and busy renderers using the Retry-After header, and turns API errors into exceptions that carry the problem code.
import { writeFile } from "node:fs/promises";
const API_URL = "https://api.trydocify.com/api/v1/generate";
/**
* Renders a document and returns the PDF bytes.
* Retries rate limits (429) and busy renderers (503), honoring Retry-After.
*/
export async function generatePdf(document, { attempts = 3 } = {}) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"X-API-Key": process.env.TRYDOCIFY_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(document),
});
if (response.ok) {
return Buffer.from(await response.arrayBuffer());
}
const retryable = response.status === 429 || response.status === 503;
if (retryable && attempt < attempts) {
const seconds = Number(response.headers.get("Retry-After")) || attempt;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
continue;
}
// Errors are RFC 9457 problem documents with a stable `code`.
const problem = await response.json();
throw new Error(`${response.status} ${problem.code}: ${problem.detail ?? problem.title}`);
}
}
const pdf = await generatePdf({
// Free, watermarked renders while you build. Remove for production.
test: process.env.NODE_ENV !== "production",
fileName: "report.pdf",
title: "Weekly report",
blocks: [
{ type: "heading", text: "Summary", level: 2 },
{ type: "paragraph", text: "Orders grew 12% week over week." },
{
type: "table",
columns: [{ header: "Region" }, { header: "Orders", align: "right" }],
rows: [["Europe", 1240], ["North America", 980]],
footer: [["Total", 2220]],
},
],
});
await writeFile("report.pdf", pdf);
console.log(`Saved report.pdf (${pdf.length} bytes)`);Run it with TRYDOCIFY_API_KEY=dfy_live_… node generate-pdf.mjs. Outside production the document carries test: true, so the PDF is watermarked and free (200 test renders a day per key). Set NODE_ENV=production on your servers to get real, unwatermarked PDFs.
Stream a PDF from Express
A common need is a URL that returns a receipt or report on demand. Build the document from your own data, call the API, and send the bytes with the right headers. Generating on request means there are no stored files to keep in sync with your data.
import express from "express";
const app = express();
// Your own data access; here a stand-in.
async function findOrder(id) {
return {
id,
customer: "Globex Corporation",
items: [
{ name: "Annual license", qty: 1, price: 490 },
{ name: "Onboarding session", qty: 2, price: 150 },
],
};
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
app.get("/orders/:id/receipt.pdf", async (req, res) => {
const order = await findOrder(req.params.id);
const total = order.items.reduce((sum, item) => sum + item.qty * item.price, 0);
const response = await fetch("https://api.trydocify.com/api/v1/generate", {
method: "POST",
headers: { "X-API-Key": process.env.TRYDOCIFY_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
test: process.env.NODE_ENV !== "production",
title: `Receipt for order ${order.id}`,
blocks: [
{ type: "keyValue", items: [{ key: "Customer", value: order.customer }] },
{
type: "table",
columns: [{ header: "Item" }, { header: "Qty", align: "right" }, { header: "Amount", align: "right" }],
rows: order.items.map((item) => [item.name, item.qty, money.format(item.qty * item.price)]),
footer: [["Total", "", money.format(total)]],
},
],
}),
});
if (!response.ok) {
const problem = await response.json();
console.error("PDF generation failed", problem.code, problem.traceId);
return res.status(502).send("Could not generate the receipt.");
}
res
.type("application/pdf")
.set("Content-Disposition", `inline; filename="receipt-${order.id}.pdf"`)
.send(Buffer.from(await response.arrayBuffer()));
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));Content-Disposition: inlineopens the PDF in the browser tab. Useattachmentto force a download.Intl.NumberFormatformats money in your locale before sending it. The API prints values exactly as you send them.- Log the problem
codeandtraceIdon failure. They are what support needs to find the request.
The same pattern works in a Next.js route handler, Fastify or NestJS: build the object, fetch, return the bytes with Content-Type: application/pdf.
Tips for production
- Keep the API key on the server. Never call the API from browser code, where the key would be visible to anyone.
- Long tables are fine: rows flow across pages and the header row repeats automatically. Up to 20,000 rows per table.
- Images must be inline base64 data URIs (PNG, JPEG or WebP, up to 2 MB). Read the file with
fs.readFileand use`data:image/png;base64,${buffer.toString("base64")}`. The invoice guide shows this for a logo. - Need JSON back instead of bytes, for a queue or a webhook? Send
Accept: application/jsonand read the base64 PDF fromdata.
Try it with your own data
Every plan includes 200 free test renders a day. Add "test": true while you build, and remove it when you go live.