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:
- Renders real HTML/CSS (flexbox, grid, media queries, webfonts)
- Works without system dependencies (runs on Lambda, Vercel, Railway, traditional servers)
- Converts in milliseconds, not seconds
- 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
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();
})();
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
});
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!');
});
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}`);
});
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);
});
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);
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;
}
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`);
});
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
- Sign up: pagebolt.dev — 100 free requests/month, no credit card
- Get API key: Copy from dashboard
-
Install axios:
npm install axios resend(for email example) - Copy example above: Pick the use case (invoice, report, certificate)
-
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)