Skip to main content

pdf-lib vs jsPDF vs pdfmake: PDF Generation in Node.js (2026)

·PkgPulse Team

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

PackageWeekly DownloadsApproachBrowser?Modify existing?
jspdf~900KCanvas-like API⚠️ Limited
pdf-lib~430KLow-level PDF ops
pdfmake~410KDocument 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

Featurepdf-libjsPDFpdfmake
TypeScript✅ Native
Browser support
Node.js support
Modify existing PDFs⚠️ Limited
Merge PDFs
HTML to PDF✅ (html2canvas)
Document modelLow-levelCanvas-likeHigh-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.

Compare document processing packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.