Skip to content
TryDocify

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.

JavaScript · generate-pdf.mjs
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.

JavaScript · server.mjs
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: inline opens the PDF in the browser tab. Use attachment to force a download.
  • Intl.NumberFormat formats money in your locale before sending it. The API prints values exactly as you send them.
  • Log the problem code and traceId on 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.readFile and 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/json and read the base64 PDF from data.

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.