Every PDF generation article ranks libraries without actually using them. This one's different.
I've shipped production code with all of these. Here's what I learned.
The Contenders
| Library | Approach | Best For |
|---|---|---|
| pdf-lib | Programmatic PDF creation | Simple documents, forms |
| Puppeteer | Headless Chrome rendering | Complex layouts |
| Playwright | Similar to Puppeteer | Cross-browser needs |
| PDFKit | Node.js PDF generation | Streaming, server-side |
| PDF APIs | External service | Production at scale |
pdf-lib
pdf-lib creates PDFs programmatically. No browser. No HTML.
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
const doc = await PDFDocument.create()
const page = doc.addPage([595, 842]) // A4
const font = await doc.embedFont(StandardFonts.Helvetica)
page.drawText('Invoice #1234', {
x: 50,
y: 800,
size: 24,
font,
color: rgb(0, 0, 0),
})
const pdfBytes = await doc.save()Pros:
- Tiny bundle (works in browser)
- Fast (no browser rendering)
- Can modify existing PDFs
- No external dependencies
Cons:
- Manual positioning for everything
- No CSS. No HTML. No layout engine.
- Complex documents become unmanageable
- Text wrapping? You implement it.
Verdict: Great for filling forms or adding watermarks. Painful for invoices or reports.
PDFKit
PDFKit is Node.js native. Similar philosophy to pdf-lib.
import PDFDocument from 'pdfkit'
import fs from 'fs'
const doc = new PDFDocument()
doc.pipe(fs.createWriteStream('output.pdf'))
doc.fontSize(24).text('Invoice #1234', 50, 50)
doc.fontSize(12).text('Line item 1', 50, 100)
doc.end()Pros:
- Streaming output (memory efficient)
- Good font support
- Mature, stable
Cons:
- Same manual positioning problem
- Node.js only (no browser)
- Layout is your problem
Verdict: Better than pdf-lib for server-side generation. Still painful for complex layouts.
Puppeteer / Playwright
Use a real browser to render HTML, then export to PDF.
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`
<html>
<head>
<style>
body { font-family: sans-serif; }
.invoice { padding: 40px; }
</style>
</head>
<body>
<div class="invoice">
<h1>Invoice #1234</h1>
<table>...</table>
</div>
</body>
</html>
`)
const pdf = await page.pdf({ format: 'A4' })
await browser.close()Pros:
- Full CSS support (flexbox, grid, the works)
- Use your existing HTML/CSS skills
- Pixel-perfect rendering
- Handles complex layouts easily
Cons:
- Heavy (Chromium is 100MB+)
- Slow (browser startup, rendering)
- Memory hungry (200MB+ per instance)
- Crashes in production (see my other post)
Verdict: Best layout engine. Worst operational characteristics.
Playwright vs Puppeteer
They're nearly identical for PDF generation.
// Playwright
import { chromium } from 'playwright'
const browser = await chromium.launch()
// Puppeteer
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch()Playwright supports Firefox and WebKit. Doesn't matter for PDFs—Chromium's PDF output is the best.
Verdict: Pick whichever you're already using. Don't switch.
wkhtmltopdf
The old guard. A Qt WebKit wrapper.
wkhtmltopdf input.html output.pdfPros:
- Simple CLI
- Lightweight compared to Chromium
Cons:
- Ancient WebKit (no modern CSS)
- No flexbox, no grid, no CSS variables
- Project is essentially abandoned
- Security vulnerabilities
Verdict: Don't use it in 2026. The CSS support is stuck in 2015.
PDF APIs
External services that handle the rendering.
// Generic API call
const response = await fetch('https://api.pdfservice.com/generate', {
method: 'POST',
headers: { Authorization: 'Bearer xxx' },
body: JSON.stringify({ html: '<h1>Invoice</h1>' }),
})
const pdf = await response.arrayBuffer()Pros:
- No Chromium to manage
- Always warm (no cold starts)
- Scales automatically
- Someone else's problem
Cons:
- Costs money
- Data leaves your server
- Latency (network round trip)
- Vendor dependency
Verdict: Best for production. You're trading money for reliability.
The Decision Matrix
| Requirement | Best Choice |
|---|---|
| Fill existing PDF forms | pdf-lib |
| Simple server-side docs | PDFKit |
| Complex layouts, low volume | Puppeteer |
| Complex layouts, production | PDF API |
| Browser-side generation | pdf-lib |
| Modify existing PDFs | pdf-lib |
My Honest Take
Here's what I actually use:
For prototypes: Puppeteer. It's the fastest to get working.
For production: An API. I'm not debugging Chromium crashes anymore.
For PDF manipulation: pdf-lib. Adding watermarks, merging files, filling forms.
The "right" answer depends on your scale. Under 100 PDFs/month? Puppeteer's fine. Over 1000? You'll spend more time on infrastructure than features.
About NeatPDF
Full disclosure: I built NeatPDF because I got tired of the Puppeteer dance.
It's a PDF API with a typed SDK. You send HTML, you get a PDF. No browser management.
import { NeatPDF } from '@neatpdf/sdk'
const client = new NeatPDF({ apiKey: process.env.NEATPDF_API_KEY })
const pdf = await client.generate({
html: '<h1>Invoice #1234</h1>',
format: 'A4',
margin: { top: '20mm', bottom: '20mm' },
})Zero-storage architecture. Your HTML goes in, PDF comes out, nothing saved.
Is it the right choice for everyone? No. If you're generating 10 PDFs a month, just use Puppeteer.
But if you're building a SaaS and PDF generation is a feature—not the product—an API makes sense.