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 ·
<strong>New users:</strong> 342 ·
<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
- Sign up at pagebolt.dev — 100 free PDF conversions/month, no credit card
- Copy your API key from the dashboard
- Replace any Puppeteer or wkhtmltopdf code with a single
fetchcall - 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