How to Generate PDFs from HTML in a Node.js App

If you've ever needed to generate a PDF from HTML in a Node.js application, you know the search results are... overwhelming. Puppeteer, wkhtmltopdf, PDFKit, jsPDF, random npm packages with 12 stars. Each comes with its own tradeoffs, and most tutorials skip the parts that actually matter in production.

This guide walks through the real options for Node.js PDF generation, compares them honestly, and shows you working code for each approach, including a clean API-based method that avoids the headless browser problem entirely.

Your Options for HTML to PDF in Node.js

There are four main approaches to generating PDFs from HTML in Node.js. Here's what each one actually involves.

Puppeteer (Headless Chrome)

Puppeteer launches a real Chromium browser, loads your HTML, and prints it to PDF. The rendering is excellent because it's literally Chrome.

const puppeteer = require('puppeteer');

async function generatePDF(html) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  const pdf = await page.pdf({ format: 'A4', printBackground: true });
  await browser.close();
  return pdf;
}

Pros: Pixel-perfect rendering, supports modern CSS (Flexbox, Grid), handles web fonts. Cons: ~400MB Chromium download, high memory usage, slow cold starts, crashes under load, painful in Docker, needs --no-sandbox flags in containers.

PDFKit

PDFKit is a low-level PDF generation library. You build documents programmatically, not from HTML.

const PDFDocument = require('pdfkit');

const doc = new PDFDocument();
doc.pipe(fs.createWriteStream('output.pdf'));
doc.fontSize(20).text('Invoice #1042', 100, 80);
doc.fontSize(12).text('Amount: $250.00', 100, 120);
doc.end();

Pros: Lightweight, no browser dependency, fine-grained control. Cons: No HTML/CSS rendering at all. You're positioning every element manually. Complex layouts become painful fast.

jsPDF

jsPDF runs in the browser and Node.js, but it's primarily a client-side library. Like PDFKit, it doesn't render HTML. You build the PDF with imperative calls.

const { jsPDF } = require('jspdf');

const doc = new jsPDF();
doc.text('Invoice #1042', 20, 20);
doc.text('Amount: $250.00', 20, 30);
doc.save('output.pdf');

Pros: Works in the browser too, simple API for basic documents. Cons: Same problem as PDFKit. No HTML rendering. Tables and complex layouts require plugins (like jspdf-autotable) and a lot of manual work.

PDF API (Template-Based)

Instead of running a rendering engine locally, you define an HTML template once and call an API with your data. The API renders the PDF and returns it.

Pros: No Chromium, no dependencies, fast, works everywhere (serverless, edge, containers). Cons: External dependency, requires network call, costs money at scale (though free tiers exist).

Why Puppeteer Becomes a Problem in Production

Puppeteer works great in development. You install it, write ten lines of code, and get a perfect PDF. The problems start when you deploy.

Memory: Each Chromium instance uses 50-150MB of RAM. If you're generating PDFs concurrently, that adds up fast. On a 512MB serverless function, you might get one or two concurrent generations before you're out of memory.

Cold starts: Launching Chromium takes 1-3 seconds. If you're generating PDFs in an API endpoint, that's a noticeable delay on every request. You can keep a browser pool alive, but that's more complexity and more memory.

Docker: Getting Puppeteer to work in Docker requires installing a list of system dependencies (fonts, graphics libraries, dbus). The Dockerfile ends up with 15 lines of apt-get install before you even get to your app. And the image balloons to 1GB+.

Serverless: AWS Lambda, Cloud Functions, and Vercel all have size and memory limits that make Puppeteer awkward. There are workarounds (@sparticuz/chromium, Lambda layers), but they're fragile and version-sensitive.

Stability: Chromium crashes. Pages hang. Memory leaks accumulate. In production, you need process monitoring, restart logic, and timeout handling around what should be a simple "render this HTML" operation.

None of this is Puppeteer's fault. It's a browser automation tool being used as a PDF renderer. It works, but the operational cost is real.

The API Approach: Template + Variables + fetch()

The idea is simple: instead of running Chromium yourself, you define your document as an HTML template and send data to an API that renders it.

With Transactional.dev, the workflow looks like this:

  1. Create a template in the dashboard using HTML and Tailwind CSS. Use Handlebars syntax for dynamic parts (variables, conditions, loops).
  2. Call the API with your template ID and variables. You get back a PDF.

That's it. No browser, no dependencies, no Docker headaches.

The template might look like this in the dashboard:

<div class="p-8 font-sans">
  <h1 class="text-2xl font-bold">Invoice {{invoiceNumber}}</h1>
  <p class="mt-2">Date: {{date}}</p>
  <p>Client: {{clientName}}</p>

  <table class="mt-6 w-full border-collapse">
    <thead>
      <tr class="border-b">
        <th class="text-left py-2">Item</th>
        <th class="text-right py-2">Amount</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr class="border-b">
        <td class="py-2">{{this.description}}</td>
        <td class="text-right py-2">{{this.amount}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <p class="mt-4 text-right font-bold">Total: {{total}}</p>
</div>

You design it visually, with live preview, then call it from code.

Complete Node.js Code Example

Here's a working example using fetch() (built into Node.js 18+):

async function generateInvoicePDF(invoiceData) {
  const response = await fetch('https://api.transactional.dev/v1/generate', {
    method: 'POST',
    headers: {
      'x-api-token': process.env.TRANSACTIONAL_API_TOKEN,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      documentId: process.env.INVOICE_TEMPLATE_ID, // UUID from the dashboard
      variables: {
        invoiceNumber: invoiceData.number,
        date: invoiceData.date,
        clientName: invoiceData.clientName,
        items: invoiceData.items,
        total: invoiceData.total,
      },
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`PDF generation failed: ${response.status} - ${error}`);
  }

  const pdfBuffer = Buffer.from(await response.arrayBuffer());
  return pdfBuffer;
}

// Usage
const pdf = await generateInvoicePDF({
  number: 'INV-1042',
  date: '2025-01-15',
  clientName: 'Acme Corp',
  items: [
    { description: 'Web development', amount: '$3,000' },
    { description: 'Hosting (annual)', amount: '$500' },
  ],
  total: '$3,500',
});

fs.writeFileSync('invoice.pdf', pdf);

No Chromium. No dependencies beyond Node.js itself. The API handles rendering, fonts, and CSS processing on its side.

Express Route Example

Here's how you'd wire this into an Express app to serve PDFs on demand:

const express = require('express');
const app = express();

app.get('/invoices/:id/pdf', async (req, res) => {
  // Fetch invoice data from your database
  const invoice = await db.invoices.findById(req.params.id);

  if (!invoice) {
    return res.status(404).json({ error: 'Invoice not found' });
  }

  try {
    const response = await fetch('https://api.transactional.dev/v1/generate', {
      method: 'POST',
      headers: {
        'x-api-token': process.env.TRANSACTIONAL_API_TOKEN,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        documentId: process.env.INVOICE_TEMPLATE_ID,
        variables: {
          invoiceNumber: invoice.number,
          date: invoice.date,
          clientName: invoice.client.name,
          items: invoice.lineItems,
          total: invoice.formattedTotal,
        },
      }),
    });

    if (!response.ok) {
      throw new Error(`API returned ${response.status}`);
    }

    const pdfBuffer = Buffer.from(await response.arrayBuffer());

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      'Content-Length': pdfBuffer.length,
    });

    res.send(pdfBuffer);
  } catch (err) {
    console.error('PDF generation error:', err);
    res.status(500).json({ error: 'Failed to generate PDF' });
  }
});

app.listen(3000);

This endpoint fetches invoice data from your database, sends it to the API, and streams the PDF back to the client. The whole thing is stateless and works fine behind a load balancer or in a serverless function.

Implementation Notes

Error handling: The API can return errors for invalid template IDs, malformed variables, or rate limits. Always check the response status and handle failures gracefully. In production, consider retry logic with exponential backoff for transient failures.

Template design: You build templates in the Transactional.dev dashboard with a live preview. Tailwind CSS is available out of the box, along with Google Fonts. You can use Handlebars helpers for conditionals ({{#if}}) and loops ({{#each}}).

File size: PDFs generated from HTML templates are typically small (50-200KB for a typical invoice). If your documents include images, host them on a CDN so the rendering engine can fetch them quickly.

Caching: If the same data produces the same PDF, consider caching the result. Store the PDF in S3 or your file system with a hash of the input variables as the key.

Security: Keep your API token in environment variables. Never commit it to source control. The token is passed via the x-api-token header, not in the URL.

Testing: You can test template rendering directly in the dashboard before writing any code. This makes it easy to iterate on the layout without redeploying your app.

Conclusion

Generating PDFs from HTML in Node.js doesn't have to mean running headless Chrome. If your use case involves templated documents (invoices, receipts, reports, contracts), an API-based approach removes the infrastructure burden entirely.

Puppeteer is a solid tool when you need to screenshot arbitrary web pages or automate browser workflows. But for structured PDF generation from known templates, it's more complexity than you need.

You can start with the free tier and generate your first PDF from an HTML template in a few minutes.