DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

HTML to PDF in Node.js: A Practical Guide

HTML to PDF in Node.js: A Practical Guide

Converting HTML to PDF in Node.js sounds simple. It isn't.

The typical approach breaks down quickly:

  • PDFKit: Only renders basic CSS, can't handle <img>, <table>, or modern layout
  • Puppeteer: Works great, but adds 200MB+ to your bundle and doesn't run on serverless
  • wkhtmltopdf: Unmaintained, system dependencies, Docker bloat
  • node-html-pdf: Abandoned project, broken with modern HTML/CSS

You need something that:

  1. Renders real HTML/CSS (flexbox, grid, media queries, webfonts)
  2. Works without system dependencies (runs on Lambda, Vercel, Railway, traditional servers)
  3. Converts in milliseconds, not seconds
  4. Doesn't require spinning up a browser

This guide shows you the practical approach: send HTML to an API, get back a PDF buffer, use it immediately (save to disk, send via email, stream to client).


The Problem: Why Self-Hosted PDF Tools Fail

Let me show you the pain points with common approaches:

PDFKit (What Doesn't Work)

const PDFDocument = require('pdfkit');
const fs = require('fs');

const doc = new PDFDocument();
doc.pipe(fs.createWriteStream('output.pdf'));

// You can only draw rectangles and text
doc.fontSize(25).text('Hello World', 100, 100);

// But you CANNOT do this:
doc.html('<h1>Styled heading</h1><p style="color: blue;">Paragraph</p>');
// ❌ Error: html() method doesn't exist
Enter fullscreen mode Exit fullscreen mode

Result: You end up manually positioning text and shapes — not scalable for invoices, reports, or certificates.

Puppeteer (The Bloat Problem)

const puppeteer = require('puppeteer'); // +200MB to your bundle

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent('<h1>Hello</h1>');
  await page.pdf({ path: 'output.pdf' });
  await browser.close();
})();
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Adds Chromium binary (~200MB)
  • Doesn't work on AWS Lambda free tier (payload limit 50MB)
  • Doesn't work on Vercel (12-second timeout for cold starts)
  • Docker bloat: node:18 + Puppeteer = 1.2GB image
  • Cost: Every PDF generation spins up a browser process (memory + CPU)

wkhtmltopdf (The Legacy Trap)

const wkhtmltopdf = require('wkhtmltopdf');

// Requires wkhtmltopdf binary installed on the system
wkhtmltopdf('<h1>Hello</h1>', options, (err, stream) => {
  // Good luck getting this working in production
});
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Requires system package (apt-get install wkhtmltopdf)
  • Breaks in Docker without proper base image
  • Unmaintained (last update: 2017)
  • Security vulnerabilities
  • Doesn't work on managed serverless (Vercel, Netlify)

The Solution: API-Based HTML to PDF

Instead of managing browser processes or system dependencies, send HTML to a dedicated PDF API. You get:

  • ✅ Real HTML5/CSS3 rendering
  • ✅ No system dependencies
  • ✅ Works on serverless (Lambda, Vercel, Railway)
  • ✅ Millisecond response times
  • ✅ Scalable (no browser per request)

Complete Example: HTML to PDF in Node.js

Here's everything you need:

1. Basic HTML to PDF

const axios = require('axios');

async function convertHtmlToPdf(htmlContent) {
  try {
    const response = await axios.post(
      'https://pagebolt.dev/api/v1/pdf',
      {
        html: htmlContent,
        margin: '0.5cm',
        landscape: false,
      },
      {
        headers: {
          'x-api-key': process.env.PAGEBOLT_API_KEY,
        },
        responseType: 'arraybuffer',
      }
    );

    // response.data is a PDF buffer
    return response.data;
  } catch (error) {
    console.error('PDF generation failed:', error.message);
    throw error;
  }
}

// Usage
const html = '<h1>Hello World</h1><p>This is a PDF.</p>';
convertHtmlToPdf(html).then((pdfBuffer) => {
  // Save to disk
  require('fs').writeFileSync('output.pdf', pdfBuffer);
  console.log('PDF saved!');
});
Enter fullscreen mode Exit fullscreen mode

2. Save PDF to Disk

const fs = require('fs').promises;
const path = require('path');

async function savePdfToDisk(htmlContent, filename) {
  const pdfBuffer = await convertHtmlToPdf(htmlContent);
  const filePath = path.join(__dirname, 'pdfs', filename);

  // Ensure directory exists
  await fs.mkdir(path.dirname(filePath), { recursive: true });

  // Write PDF to disk
  await fs.writeFile(filePath, pdfBuffer);

  return filePath;
}

// Usage
savePdfToDisk('<h1>Invoice</h1>', 'invoice-123.pdf').then((path) => {
  console.log(`Saved to: ${path}`);
});
Enter fullscreen mode Exit fullscreen mode

3. Email PDF as Attachment (with Resend)

const { Resend } = require('resend');
const resend = new Resend(process.env.RESEND_API_KEY);

async function emailPdfInvoice(toEmail, invoiceHtml, invoiceNumber) {
  const pdfBuffer = await convertHtmlToPdf(invoiceHtml);

  const response = await resend.emails.send({
    from: 'invoices@yourcompany.com',
    to: toEmail,
    subject: `Invoice #${invoiceNumber}`,
    html: `<p>Your invoice is attached.</p>`,
    attachments: [
      {
        filename: `invoice-${invoiceNumber}.pdf`,
        content: pdfBuffer,
      },
    ],
  });

  return response;
}

// Usage
const invoiceHtml = `
  <h1>Invoice #001</h1>
  <p>Amount: $500</p>
  <p>Due: 2026-04-25</p>
`;

emailPdfInvoice('customer@example.com', invoiceHtml, '001').then((result) => {
  console.log('Invoice emailed:', result.id);
});
Enter fullscreen mode Exit fullscreen mode

4. Stream PDF to HTTP Response (Express)

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

app.get('/download-report', async (req, res) => {
  try {
    const htmlContent = `
      <h1>Monthly Report</h1>
      <table>
        <tr><th>Month</th><th>Revenue</th></tr>
        <tr><td>January</td><td>$10,000</td></tr>
        <tr><td>February</td><td>$12,500</td></tr>
      </table>
    `;

    const pdfBuffer = await convertHtmlToPdf(htmlContent);

    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', 'attachment; filename=report.pdf');
    res.send(pdfBuffer);
  } catch (error) {
    res.status(500).json({ error: 'PDF generation failed' });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

5. Generate Certificate PDFs (Loop)

async function generateCertificates(students) {
  const certificates = [];

  for (const student of students) {
    const html = `
      <html>
        <head>
          <style>
            body {
              text-align: center;
              font-family: serif;
              padding: 50px;
            }
            h1 { font-size: 48px; margin-bottom: 50px; }
            p { font-size: 24px; margin-bottom: 20px; }
            .signature { margin-top: 80px; font-size: 14px; }
          </style>
        </head>
        <body>
          <h1>Certificate of Completion</h1>
          <p>This certifies that</p>
          <p style="font-weight: bold; font-size: 32px;">${student.name}</p>
          <p>has successfully completed</p>
          <p style="font-weight: bold;">${student.course}</p>
          <p>on ${new Date().toLocaleDateString()}</p>
          <div class="signature">
            <p>Authorized Signature</p>
          </div>
        </body>
      </html>
    `;

    const pdfBuffer = await convertHtmlToPdf(html);
    certificates.push({
      studentId: student.id,
      filename: `certificate-${student.id}.pdf`,
      buffer: pdfBuffer,
    });
  }

  return certificates;
}
Enter fullscreen mode Exit fullscreen mode

Real-World: Bulk PDF Generation

For generating 1000 invoices? Don't loop sequentially. Use Promise.all:

async function generateInvoicesBulk(invoices) {
  // Create PDF promises for all invoices in parallel
  const pdfPromises = invoices.map((invoice) => {
    const html = `
      <h1>Invoice #${invoice.number}</h1>
      <p>Customer: ${invoice.customer}</p>
      <p>Amount: $${invoice.amount}</p>
    `;
    return convertHtmlToPdf(html);
  });

  // Wait for all PDFs to generate in parallel (not sequentially)
  const pdfBuffers = await Promise.all(pdfPromises);

  // Save or process all at once
  return pdfBuffers.map((buffer, idx) => ({
    invoiceId: invoices[idx].id,
    buffer: buffer,
  }));
}

// Usage: Generate 1000 invoices in parallel
const invoices = Array.from({ length: 1000 }, (_, i) => ({
  number: i + 1,
  customer: `Customer ${i + 1}`,
  amount: Math.random() * 10000,
}));

generateInvoicesBulk(invoices).then((results) => {
  console.log(`Generated ${results.length} PDFs in parallel`);
});
Enter fullscreen mode Exit fullscreen mode

Comparison: Self-Hosted vs API

Factor Puppeteer wkhtmltopdf PDFKit PageBolt API
Setup time 5 mins 20 mins 2 mins 2 mins
HTML/CSS support Full Good Minimal Full
Serverless-friendly ❌ No ❌ No ✅ Yes ✅ Yes
Bundle size +200MB +50MB +5MB 0 bytes
Per-PDF cost Infra Infra Free $0.006–$0.05
Response time 2–5s 1–3s 0.5s 100–300ms
Scaling Horizontal Horizontal Horizontal Automatic
Memory per PDF 100MB+ 50MB+ 1MB <1MB

For high-volume PDF generation (1000+ per day), the API always wins on cost and complexity.


Getting Started

  1. Sign up: pagebolt.dev — 100 free requests/month, no credit card
  2. Get API key: Copy from dashboard
  3. Install axios: npm install axios resend (for email example)
  4. Copy example above: Pick the use case (invoice, report, certificate)
  5. Test: node script.js

Your HTML-to-PDF pipeline is now production-ready, zero-dependency, serverless-compatible, and scalable.

Try it free — 100 requests/month, no credit card. Start now.

Top comments (0)