pdf-lib vs jsPDF vs pdfmake: PDF Generation in Node.js (2026)
TL;DR
pdf-lib is the modern choice for 2026 — pure TypeScript, no native dependencies, works in Node.js and browsers, and gives you full programmatic control over PDF structure. jsPDF is the oldest and most-downloaded option, best for quick HTML-to-PDF scenarios. pdfmake is ideal for document-definition-based reports where non-developers define templates — think invoices, contracts, and multi-page formatted documents.
Key Takeaways
- pdf-lib: ~430K weekly downloads — TypeScript-native, modifies existing PDFs + creates new ones, no native deps
- jsPDF: ~900K weekly downloads — oldest library, canvas-like API, excellent HTML capture via html2canvas
- pdfmake: ~410K weekly downloads — document definition object model, table/column layouts, server + browser
- Use pdf-lib when creating PDFs programmatically with precise control
- Use jsPDF for HTML → PDF conversion or quick document generation
- Use pdfmake for report-style PDFs driven by structured data templates
Download Trends
| Package | Weekly Downloads | Approach | Browser? | Modify existing? |
|---|---|---|---|---|
jspdf | ~900K | Canvas-like API | ✅ | ⚠️ Limited |
pdf-lib | ~430K | Low-level PDF ops | ✅ | ✅ |
pdfmake | ~410K | Document definition | ✅ | ❌ |
pdf-lib
pdf-lib is a modern PDF library that can create PDFs from scratch OR modify existing PDFs. Pure TypeScript, no native dependencies, runs anywhere JavaScript runs.
Creating a PDF from Scratch
import { PDFDocument, StandardFonts, rgb, degrees, PageSizes } from "pdf-lib"
import { readFileSync, writeFileSync } from "fs"
async function createPackageReport(packages: PackageData[]) {
const pdfDoc = await PDFDocument.create()
// Embed standard fonts (no external font files needed):
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
// Add a page (A4):
const page = pdfDoc.addPage(PageSizes.A4)
const { width, height } = page.getSize()
// Draw header background:
page.drawRectangle({
x: 0,
y: height - 80,
width,
height: 80,
color: rgb(0.18, 0.42, 0.31), // #2D6A4F
})
// Title:
page.drawText("Package Download Report", {
x: 40,
y: height - 50,
size: 24,
font: helveticaBold,
color: rgb(1, 1, 1),
})
// Subtitle:
page.drawText(`Generated ${new Date().toLocaleDateString()}`, {
x: 40,
y: height - 70,
size: 12,
font: helvetica,
color: rgb(0.9, 0.9, 0.9),
})
// Draw table:
const tableTop = height - 120
const rowHeight = 30
const colWidths = [200, 150, 100, 80]
const colX = [40, 240, 390, 490]
// Header row:
const headers = ["Package", "Weekly Downloads", "Version", "License"]
headers.forEach((header, i) => {
page.drawRectangle({
x: colX[i],
y: tableTop - rowHeight,
width: colWidths[i],
height: rowHeight,
color: rgb(0.95, 0.95, 0.95),
})
page.drawText(header, {
x: colX[i] + 8,
y: tableTop - rowHeight + 10,
size: 10,
font: helveticaBold,
color: rgb(0, 0, 0),
})
})
// Data rows:
packages.forEach((pkg, rowIndex) => {
const y = tableTop - (rowIndex + 2) * rowHeight
// Zebra striping:
if (rowIndex % 2 === 0) {
page.drawRectangle({
x: 40,
y,
width: width - 80,
height: rowHeight,
color: rgb(0.98, 0.98, 0.98),
})
}
const values = [
pkg.name,
pkg.weeklyDownloads.toLocaleString(),
pkg.version,
pkg.license,
]
values.forEach((value, colIndex) => {
page.drawText(value, {
x: colX[colIndex] + 8,
y: y + 10,
size: 9,
font: helvetica,
color: rgb(0.2, 0.2, 0.2),
})
})
})
// Footer:
page.drawText("PkgPulse.com — Package ecosystem analytics", {
x: 40,
y: 20,
size: 9,
font: helvetica,
color: rgb(0.5, 0.5, 0.5),
})
const pdfBytes = await pdfDoc.save()
return pdfBytes
}
Modifying an Existing PDF
The feature that distinguishes pdf-lib from competitors:
import { PDFDocument, rgb } from "pdf-lib"
async function addWatermarkToPdf(inputPdfBytes: Uint8Array) {
const pdfDoc = await PDFDocument.load(inputPdfBytes)
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
const pages = pdfDoc.getPages()
for (const page of pages) {
const { width, height } = page.getSize()
// Add diagonal watermark:
page.drawText("CONFIDENTIAL", {
x: width / 4,
y: height / 2,
size: 60,
font: helvetica,
color: rgb(0.9, 0.1, 0.1),
opacity: 0.2,
rotate: degrees(45),
})
}
return pdfDoc.save()
}
// Merge multiple PDFs:
async function mergePdfs(pdfs: Uint8Array[]) {
const mergedPdf = await PDFDocument.create()
for (const pdfBytes of pdfs) {
const pdf = await PDFDocument.load(pdfBytes)
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices())
copiedPages.forEach((page) => mergedPdf.addPage(page))
}
return mergedPdf.save()
}
// Extract pages:
async function extractPages(inputPdfBytes: Uint8Array, pageIndices: number[]) {
const pdfDoc = await PDFDocument.load(inputPdfBytes)
const newDoc = await PDFDocument.create()
const pages = await newDoc.copyPages(pdfDoc, pageIndices)
pages.forEach((page) => newDoc.addPage(page))
return newDoc.save()
}
pdf-lib with custom fonts:
import { PDFDocument, PDFFont } from "pdf-lib"
import fontkit from "@pdf-lib/fontkit"
import { readFileSync } from "fs"
const pdfDoc = await PDFDocument.create()
pdfDoc.registerFontkit(fontkit)
// Embed TTF font:
const interBytes = readFileSync("./fonts/Inter-Regular.ttf")
const interFont = await pdfDoc.embedFont(interBytes)
// Now use interFont in drawText calls
jsPDF
jsPDF is the most widely downloaded PDF library — built for browser-first use cases and HTML-to-PDF capture.
import jsPDF from "jspdf"
import html2canvas from "html2canvas"
// Basic document creation:
function createSimpleReport(packages: PackageData[]) {
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
})
// Title:
doc.setFontSize(22)
doc.setFont("helvetica", "bold")
doc.text("Package Report", 20, 30)
// Subheading:
doc.setFontSize(12)
doc.setFont("helvetica", "normal")
doc.setTextColor(100, 100, 100)
doc.text(`Generated ${new Date().toLocaleDateString()}`, 20, 42)
// Line separator:
doc.setDrawColor(200, 200, 200)
doc.line(20, 48, 190, 48)
// Table header:
doc.setFillColor(45, 106, 79)
doc.rect(20, 54, 170, 10, "F")
doc.setTextColor(255, 255, 255)
doc.setFontSize(10)
doc.text("Package", 24, 61)
doc.text("Downloads/wk", 90, 61)
doc.text("Version", 145, 61)
// Table rows:
let y = 74
packages.forEach((pkg, i) => {
if (i % 2 === 0) {
doc.setFillColor(248, 248, 248)
doc.rect(20, y - 6, 170, 10, "F")
}
doc.setTextColor(30, 30, 30)
doc.setFontSize(9)
doc.text(pkg.name, 24, y)
doc.text(pkg.weeklyDownloads.toLocaleString(), 90, y)
doc.text(pkg.version, 145, y)
y += 12
// New page if needed:
if (y > 270) {
doc.addPage()
y = 30
}
})
// Save or return as blob:
doc.save("packages-report.pdf")
// Or: return doc.output("blob") / doc.output("arraybuffer")
}
jsPDF + html2canvas — the killer combo:
// Capture any DOM element as PDF:
async function exportDashboardAsPdf(elementId: string) {
const element = document.getElementById(elementId)!
// Render DOM to canvas:
const canvas = await html2canvas(element, {
scale: 2, // 2x resolution for crisp PDF
useCORS: true, // Allow cross-origin images
logging: false,
})
const imgData = canvas.toDataURL("image/png")
const pdf = new jsPDF({
orientation: canvas.width > canvas.height ? "landscape" : "portrait",
unit: "px",
format: [canvas.width / 2, canvas.height / 2],
})
pdf.addImage(imgData, "PNG", 0, 0, canvas.width / 2, canvas.height / 2)
pdf.save("dashboard.pdf")
}
jsPDF limitations:
- Canvas-based API — no notion of reflow, pages, or document structure
- HTML-to-PDF via html2canvas is a screenshot, not a real PDF render
- Text selection and copy/paste don't work on html2canvas-captured PDFs
- No support for modifying existing PDFs
pdfmake
pdfmake uses a document definition object — you describe what the document should contain (tables, lists, columns, text) and pdfmake handles layout.
import pdfMake from "pdfmake/build/pdfmake"
import pdfFonts from "pdfmake/build/vfs_fonts"
pdfMake.vfs = pdfFonts.pdfMake.vfs
function createInvoice(order: OrderData) {
const docDefinition = {
pageSize: "A4",
pageMargins: [40, 60, 40, 60],
// Header (appears on every page):
header: {
columns: [
{ text: "PkgPulse", style: "headerLogo" },
{ text: `Invoice #${order.id}`, style: "headerRight", alignment: "right" },
],
margin: [40, 20, 40, 20],
},
// Footer with page numbers:
footer: (currentPage: number, pageCount: number) => ({
text: `Page ${currentPage} of ${pageCount}`,
alignment: "center",
style: "footer",
}),
content: [
// Title section:
{
columns: [
{
stack: [
{ text: "INVOICE", style: "invoiceTitle" },
{ text: `Date: ${new Date(order.date).toLocaleDateString()}`, style: "meta" },
{ text: `Due: ${new Date(order.dueDate).toLocaleDateString()}`, style: "meta" },
],
},
{
stack: [
{ text: "Bill To:", style: "label" },
{ text: order.customer.name, style: "customerName" },
{ text: order.customer.email, style: "meta" },
{ text: order.customer.address, style: "meta" },
],
alignment: "right",
},
],
},
// Spacer:
{ text: "", margin: [0, 20, 0, 0] },
// Items table:
{
table: {
headerRows: 1,
widths: ["*", 80, 80, 80],
body: [
// Header row:
[
{ text: "Description", style: "tableHeader" },
{ text: "Qty", style: "tableHeader", alignment: "center" },
{ text: "Unit Price", style: "tableHeader", alignment: "right" },
{ text: "Total", style: "tableHeader", alignment: "right" },
],
// Data rows:
...order.items.map((item) => [
{ text: item.description },
{ text: item.quantity.toString(), alignment: "center" },
{ text: `$${item.unitPrice.toFixed(2)}`, alignment: "right" },
{ text: `$${(item.quantity * item.unitPrice).toFixed(2)}`, alignment: "right" },
]),
],
},
layout: "lightHorizontalLines",
},
// Totals:
{
columns: [
{ text: "" }, // Empty left column
{
table: {
widths: [100, 80],
body: [
["Subtotal:", `$${order.subtotal.toFixed(2)}`],
["Tax (10%):", `$${order.tax.toFixed(2)}`],
[{ text: "Total:", bold: true }, { text: `$${order.total.toFixed(2)}`, bold: true }],
],
},
layout: "noBorders",
alignment: "right",
},
],
margin: [0, 20, 0, 0],
},
// Payment instructions:
{
text: "Payment Instructions",
style: "sectionHeader",
margin: [0, 30, 0, 10],
},
{ text: order.paymentInstructions, style: "body" },
],
// Styles:
styles: {
invoiceTitle: { fontSize: 28, bold: true, color: "#2D6A4F" },
headerLogo: { fontSize: 18, bold: true, color: "#2D6A4F" },
headerRight: { fontSize: 11, color: "#666" },
tableHeader: { bold: true, fillColor: "#2D6A4F", color: "white", margin: [4, 6, 4, 6] },
label: { fontSize: 10, color: "#666", bold: true },
customerName: { fontSize: 13, bold: true },
meta: { fontSize: 10, color: "#555" },
sectionHeader: { fontSize: 14, bold: true, color: "#333" },
body: { fontSize: 10, color: "#555", lineHeight: 1.4 },
footer: { fontSize: 9, color: "#aaa" },
},
}
// Browser: download
pdfMake.createPdf(docDefinition).download("invoice.pdf")
// Browser: open in new tab
pdfMake.createPdf(docDefinition).open()
// Node.js: get buffer
return new Promise<Buffer>((resolve) => {
pdfMake.createPdf(docDefinition).getBuffer((buffer) => resolve(buffer))
})
}
pdfmake strengths:
- The document definition object is JSON-serializable — templates can be stored in a database
- Automatic page breaks and text reflowing — no manual coordinate calculations
- Column layouts, nested tables, and complex grid structures
- Non-developers can define document structure without writing coordinate math
- Strong multi-language and RTL support
Feature Comparison
| Feature | pdf-lib | jsPDF | pdfmake |
|---|---|---|---|
| TypeScript | ✅ Native | ✅ | ✅ |
| Browser support | ✅ | ✅ | ✅ |
| Node.js support | ✅ | ✅ | ✅ |
| Modify existing PDFs | ✅ | ⚠️ Limited | ❌ |
| Merge PDFs | ✅ | ❌ | ❌ |
| HTML to PDF | ❌ | ✅ (html2canvas) | ❌ |
| Document model | Low-level | Canvas-like | High-level declarative |
| Auto page breaks | ❌ (manual) | ❌ | ✅ |
| Custom fonts | ✅ TTF/OTF | ✅ | ✅ |
| Tables | ✅ (manual) | ⚠️ Plugin | ✅ Built-in |
| Images | ✅ | ✅ | ✅ |
| Watermarks | ✅ | ✅ | ✅ |
| Native deps | ❌ None | ❌ None | ❌ None |
| Bundle size | ~100KB | ~350KB | ~200KB |
When to Use Each
Choose pdf-lib if:
- You need to modify, merge, or extract pages from existing PDFs
- Building a PDF editor or form-fill application
- You want precise pixel-level control over every element
- No external binary dependencies is a hard requirement
Choose jsPDF if:
- You need HTML → PDF conversion (use with html2canvas)
- Capturing rendered React/HTML components as downloadable PDFs
- Simple single-page documents where canvas-like API is sufficient
- Already using jsPDF in an existing codebase
Choose pdfmake if:
- Generating invoice-style, report-style, or multi-page structured documents
- The document structure comes from data (rows in a table, items in an order)
- Non-developers need to define or modify document templates
- You need automatic text reflow, multi-column layouts, or complex table structures
Methodology
Download data from npm registry (weekly average, February 2026). Bundle sizes from bundlephobia. Feature comparison based on pdf-lib v1.17.x, jsPDF v2.x, and pdfmake v0.2.x documentation.