HTML to PDF January 14, 2026 · 10 min read

HTML to PDF in Node.js with Puppeteer: Complete Tutorial

Step-by-step guide to generating high-quality PDFs from HTML in Node.js using Puppeteer, with real-world examples for invoices and reports.

HTML to PDF in Node.js with Puppeteer: Complete Tutorial
AT

AltoUnlockPDF Team

PDF Tools Expert

Node.js is one of the most popular environments for server-side PDF generation. Whether you’re building an invoicing SaaS, a reporting dashboard, or an automated document workflow, generating PDFs from HTML templates is a core skill.

In this tutorial, we’ll build a complete HTML-to-PDF service in Node.js using Puppeteer — the most reliable option for high-quality output.


Why Puppeteer for PDF Generation?

Puppeteer spins up a headless Chromium browser and renders your HTML exactly as Google Chrome would. That means:

  • Perfect CSS support — Flexbox, Grid, custom fonts, all work
  • JavaScript execution — dynamic content is fully rendered
  • @page CSS rules — proper margins, headers, footers
  • Background images and colors — correctly included

The tradeoff is size (~300MB) and memory usage. For most server-side applications, that’s acceptable.


Step 1: Setup

mkdir pdf-service && cd pdf-service
npm init -y
npm install puppeteer express

Step 2: Basic PDF Generation

// pdf-generator.js
import puppeteer from 'puppeteer';

export async function htmlToPdf(htmlContent, options = {}) {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox'], // needed in Docker
  });

  const page = await browser.newPage();

  await page.setContent(htmlContent, {
    waitUntil: 'networkidle0', // wait for fonts/images to load
  });

  const pdf = await page.pdf({
    format: options.format || 'A4',
    printBackground: true,
    margin: {
      top: options.margin || '15mm',
      bottom: options.margin || '15mm',
      left: '12mm',
      right: '12mm',
    },
    displayHeaderFooter: options.footer ? true : false,
    footerTemplate: options.footer || '',
  });

  await browser.close();
  return pdf; // Buffer
}
Node.js code for PDF generation

Step 3: Build an Express API Endpoint

// server.js
import express from 'express';
import { htmlToPdf } from './pdf-generator.js';

const app = express();
app.use(express.json({ limit: '5mb' }));

app.post('/generate-pdf', async (req, res) => {
  const { html, filename = 'document.pdf', options = {} } = req.body;

  if (!html) {
    return res.status(400).json({ error: 'HTML content is required' });
  }

  try {
    const pdfBuffer = await htmlToPdf(html, options);
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
    res.send(pdfBuffer);
  } catch (err) {
    console.error('PDF generation error:', err);
    res.status(500).json({ error: 'PDF generation failed' });
  }
});

app.listen(3000, () => console.log('PDF service running on port 3000'));

Step 4: Real-World Invoice Template

function buildInvoiceHtml(invoice) {
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <style>
        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'Inter', sans-serif; color: #1a1a1a; padding: 40px; }
        .header { display: flex; justify-content: space-between; margin-bottom: 40px; }
        .company-name { font-size: 28px; font-weight: 700; color: #dd7d24; }
        .invoice-title { font-size: 32px; font-weight: 700; color: #333; }
        table { width: 100%; border-collapse: collapse; margin-top: 30px; }
        th { background: #f5f0e8; padding: 12px; text-align: left; font-weight: 600; }
        td { padding: 12px; border-bottom: 1px solid #eee; }
        .total { font-size: 20px; font-weight: 700; text-align: right; margin-top: 20px; }
        @page { margin: 0; }
      </style>
    </head>
    <body>
      <div class="header">
        <div class="company-name">${invoice.companyName}</div>
        <div>
          <div class="invoice-title">INVOICE</div>
          <div>#${invoice.invoiceNumber}</div>
          <div>${invoice.date}</div>
        </div>
      </div>
      <table>
        <tr><th>Description</th><th>Qty</th><th>Price</th><th>Total</th></tr>
        ${invoice.items.map(item => `
          <tr>
            <td>${item.description}</td>
            <td>${item.qty}</td>
            <td>$${item.price}</td>
            <td>$${(item.qty * item.price).toFixed(2)}</td>
          </tr>
        `).join('')}
      </table>
      <div class="total">Total: $${invoice.total}</div>
    </body>
    </html>
  `;
}

Step 5: Performance — Reuse the Browser

Creating a new Puppeteer browser for every request is expensive. Use a browser pool:

import puppeteer from 'puppeteer';
import genericPool from 'generic-pool';

const factory = {
  create: () => puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }),
  destroy: browser => browser.close(),
};

const browserPool = genericPool.createPool(factory, {
  min: 1,
  max: 5, // max 5 concurrent browsers
});

export async function htmlToPdfPooled(html) {
  const browser = await browserPool.acquire();
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    await page.close();
    return pdf;
  } finally {
    browserPool.release(browser);
  }
}
Generated PDF invoice from Node.js

Docker Deployment

FROM node:20-slim
RUN apt-get update && apt-get install -y \
    chromium \
    --no-install-recommends
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Conclusion

Puppeteer is the gold standard for HTML-to-PDF in Node.js. With a browser pool and a clean Express API, you can handle hundreds of PDF generations per minute on modest hardware. The official Puppeteer documentation is excellent and worth bookmarking.

For lighter workloads or client-only use cases, consider html2pdf.js instead.

Related Articles