Tutorial Mar 25, 2026

HTML to PDF in Node.js: A Practical Guide

Convert HTML to PDF in Node.js without Puppeteer bloat. Real examples: reports, invoices, certificates. One API call.

Converting HTML to PDF in Node.js is one of those tasks that looks trivial until you actually try it. Every option has a catch.

The Problem with Common Approaches

PDFKit

PDFKit generates PDFs programmatically — you describe the layout in JavaScript rather than HTML. That means rewriting your existing HTML templates from scratch:

import PDFDocument from 'pdfkit';
import fs from 'fs';

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

// You describe layout in code, not HTML
doc.fontSize(25).text('Invoice #1234', 100, 80);
doc.fontSize(14).text('Customer: Acme Corp', 100, 120);
// ... dozens more lines for a simple invoice

doc.end();

If you already have an HTML template — for your invoices, reports, or certificates — PDFKit means throwing it away and rebuilding the layout in a completely different paradigm. No CSS. No flexbox. Just coordinates.

Puppeteer

Puppeteer can convert HTML to PDF accurately because it uses a real browser. But the operational cost is high:

import puppeteer from 'puppeteer';

// 200MB+ download on first install
// Requires Chrome/Chromium at runtime
// Does NOT work on Vercel/Lambda without extra config
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdf = await page.pdf({ format: 'A4' });
await browser.close();
// Crashes on serverless. Needs --no-sandbox in Docker.
// Cold starts take 3-5 seconds on Lambda.

On AWS Lambda, Puppeteer requires chrome-aws-lambda (a 50MB layer) and custom configuration. On Vercel, it hits the 50MB function size limit. In Docker, you need --no-sandbox and a bunch of system deps. Every environment is a different battle.

wkhtmltopdf

wkhtmltopdf is a command-line tool that uses an old version of WebKit. CSS support is frozen at roughly 2013 levels — no CSS Grid, no flexbox, broken custom fonts. It requires a system binary, which means it can't run on serverless platforms at all. And it's been unmaintained since 2020.

import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

// Requires wkhtmltopdf installed as a system binary
// Broken flexbox. No CSS Grid. Old WebKit engine.
// Not available on Vercel, Lambda, or most PaaS platforms.
await execAsync(`wkhtmltopdf input.html output.pdf`);

The Practical Solution: One API Call

PageBolt's PDF API runs a real Chromium browser in the cloud. You POST your HTML, get back a PDF. No browser to manage. No system dependencies. Works everywhere fetch works.

Example 1: Basic HTML to PDF Conversion

const html = `
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 40px; color: #111; }
    h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; }
    p { color: #555; line-height: 1.7; }
    .highlight { background: #f0f9ff; padding: 16px; border-radius: 8px; border-left: 4px solid #3b82f6; }
  </style>
</head>
<body>
  <h1>Monthly Report — March 2026</h1>
  <p>Generated on ${new Date().toLocaleDateString()}</p>
  <div class="highlight">
    <strong>Revenue:</strong> $12,450 &nbsp;·&nbsp;
    <strong>New users:</strong> 342 &nbsp;·&nbsp;
    <strong>Churn:</strong> 2.1%
  </div>
</body>
</html>
`;

const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.PAGEBOLT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ html }),
});

const pdfBuffer = Buffer.from(await response.arrayBuffer());
console.log(`PDF size: ${pdfBuffer.length} bytes`);

Example 2: Save PDF to Disk

import fs from 'fs/promises';
import path from 'path';

async function htmlFileToPdf(htmlPath: string, outputPath: string): Promise<void> {
  const html = await fs.readFile(htmlPath, 'utf-8');

  const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
        printBackground: true,
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`PDF generation failed: ${response.status} ${response.statusText}`);
  }

  const pdfBuffer = Buffer.from(await response.arrayBuffer());
  await fs.writeFile(outputPath, pdfBuffer);
  console.log(`Saved PDF to ${outputPath} (${pdfBuffer.length} bytes)`);
}

// Usage
await htmlFileToPdf('./reports/march-2026.html', './output/march-2026.pdf');

Example 3: Email PDF with Resend

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function generateAndEmailPdf(options: {
  html: string;
  recipientEmail: string;
  recipientName: string;
  subject: string;
  filename: string;
}): Promise<void> {
  // Generate PDF
  const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html: options.html }),
  });

  if (!response.ok) throw new Error(`PDF failed: ${response.status}`);
  const pdfBuffer = Buffer.from(await response.arrayBuffer());

  // Email with attachment
  const { data, error } = await resend.emails.send({
    from: 'reports@yourcompany.com',
    to: options.recipientEmail,
    subject: options.subject,
    html: `
      <p>Hi ${options.recipientName},</p>
      <p>Please find your document attached.</p>
      <p>— Your Company Team</p>
    `,
    attachments: [
      {
        filename: options.filename,
        content: pdfBuffer.toString('base64'),
      },
    ],
  });

  if (error) throw new Error(`Email failed: ${JSON.stringify(error)}`);
  console.log(`Email sent: ${data?.id}`);
}

// Usage
await generateAndEmailPdf({
  html: reportHtml,
  recipientEmail: 'user@example.com',
  recipientName: 'Alice',
  subject: 'Your March 2026 Report',
  filename: 'march-2026-report.pdf',
});

Example 4: Stream PDF to Express Response

import express from 'express';

const app = express();
app.use(express.json());

app.get('/reports/:id/pdf', async (req, res) => {
  const report = await db.getReport(req.params.id);
  if (!report) return res.status(404).json({ error: 'Report not found' });

  const html = renderReportHtml(report);

  const pdfResponse = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html }),
  });

  if (!pdfResponse.ok) {
    return res.status(500).json({ error: 'PDF generation failed' });
  }

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

  // Set headers for browser download or inline viewing
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `attachment; filename="report-${req.params.id}.pdf"`);
  res.setHeader('Content-Length', pdfBuffer.length);
  res.send(pdfBuffer);
});

// Or stream inline (opens in browser PDF viewer instead of downloading)
app.get('/reports/:id/view', async (req, res) => {
  const report = await db.getReport(req.params.id);
  const html = renderReportHtml(report);

  const pdfResponse = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html }),
  });

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

  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `inline; filename="report-${req.params.id}.pdf"`);
  res.send(pdfBuffer);
});

Example 5: Generate Certificates

async function generateCertificate(recipient: {
  name: string;
  course: string;
  completedDate: string;
  certificateId: string;
}): Promise<Buffer> {
  const html = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500&display=swap');
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      width: 297mm;
      height: 210mm;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #fffdf7;
      font-family: 'Inter', sans-serif;
    }
    .certificate {
      width: 260mm;
      height: 180mm;
      border: 3px solid #c9a84c;
      padding: 40px 60px;
      text-align: center;
      position: relative;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 16px;
    }
    .certificate::before {
      content: '';
      position: absolute;
      inset: 8px;
      border: 1px solid #c9a84c;
      opacity: 0.4;
      pointer-events: none;
    }
    .org { font-size: 13px; letter-spacing: 0.2em; text-transform: uppercase; color: #9a7d3a; }
    h1 { font-family: 'Playfair Display', serif; font-size: 42px; color: #1a1a1a; line-height: 1.2; }
    .presented-to { font-size: 14px; color: #666; letter-spacing: 0.1em; text-transform: uppercase; }
    .recipient { font-family: 'Playfair Display', serif; font-size: 36px; color: #c9a84c; }
    .body-text { font-size: 15px; color: #555; line-height: 1.7; max-width: 420px; }
    .course { font-weight: 600; color: #1a1a1a; }
    .footer { display: flex; justify-content: space-between; width: 100%; margin-top: 8px; }
    .footer-item { text-align: center; }
    .footer-label { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 4px; }
    .footer-value { font-size: 13px; color: #333; font-weight: 500; border-top: 1px solid #ddd; padding-top: 6px; }
  </style>
</head>
<body>
  <div class="certificate">
    <div class="org">Your Academy</div>
    <h1>Certificate of Completion</h1>
    <div class="presented-to">This certifies that</div>
    <div class="recipient">${recipient.name}</div>
    <p class="body-text">
      has successfully completed the course
      <span class="course">${recipient.course}</span>
      and demonstrated mastery of all required competencies.
    </p>
    <div class="footer">
      <div class="footer-item">
        <div class="footer-label">Date</div>
        <div class="footer-value">${recipient.completedDate}</div>
      </div>
      <div class="footer-item">
        <div class="footer-label">Certificate ID</div>
        <div class="footer-value">${recipient.certificateId}</div>
      </div>
    </div>
  </div>
</body>
</html>
  `;

  const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        landscape: true,
        margin: { top: '0', right: '0', bottom: '0', left: '0' },
        printBackground: true,
      },
    }),
  });

  return Buffer.from(await response.arrayBuffer());
}

// Usage
const certBuffer = await generateCertificate({
  name: 'Alice Johnson',
  course: 'Advanced TypeScript',
  completedDate: 'March 25, 2026',
  certificateId: 'CERT-2026-00842',
});

await fs.writeFile('./certificates/alice-johnson.pdf', certBuffer);

Bulk PDF Generation

Need to generate hundreds of PDFs — end-of-month reports, certificates for a cohort, invoices for all users? Use a concurrency-limited batch approach so you don't hammer the API:

import pLimit from 'p-limit';

interface PdfJob {
  id: string;
  html: string;
  outputPath: string;
}

async function generatePdfBatch(
  jobs: PdfJob[],
  concurrency = 10
): Promise<{ success: string[]; failed: string[] }> {
  const limit = pLimit(concurrency);
  const success: string[] = [];
  const failed: string[] = [];

  await Promise.all(
    jobs.map((job) =>
      limit(async () => {
        try {
          const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
            method: 'POST',
            headers: {
              'x-api-key': process.env.PAGEBOLT_API_KEY!,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ html: job.html }),
          });

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

          const pdfBuffer = Buffer.from(await response.arrayBuffer());
          await fs.writeFile(job.outputPath, pdfBuffer);
          success.push(job.id);
          console.log(`Generated PDF: ${job.id}`);
        } catch (err) {
          failed.push(job.id);
          console.error(`Failed PDF ${job.id}:`, err);
        }
      })
    )
  );

  return { success, failed };
}

// Generate 200 invoices in parallel (10 at a time)
const users = await db.getAllActiveUsers();
const jobs = users.map((user) => ({
  id: user.id,
  html: renderInvoiceHtml(user),
  outputPath: `./invoices/${user.id}.pdf`,
}));

const { success, failed } = await generatePdfBatch(jobs, 10);
console.log(`Done: ${success.length} succeeded, ${failed.length} failed`);

Comparison: Node.js PDF Libraries

Approach CSS Support Serverless Setup Maintenance
PageBolt API Full HTML5/CSS3 Yes 2 min None
Puppeteer Full (Chrome) Difficult 45+ min High
PDFKit None (code-only) Yes Hours Low
wkhtmltopdf Partial (2013 WebKit) No 30+ min None (abandoned)
jsPDF Basic HTML only Yes 15 min Low

Getting Started

  1. Sign up at pagebolt.dev — 100 free PDF conversions/month, no credit card
  2. Copy your API key from the dashboard
  3. Replace any Puppeteer or wkhtmltopdf code with a single fetch call
  4. Deploy anywhere — Vercel, Lambda, Fly, Railway, traditional VPS

Convert HTML to PDF without the infrastructure headache

No Puppeteer. No system dependencies. No Docker nightmares. One API call — works on every platform. 100 free conversions/month.

Get your free API key