Introduction
pdf-lib is a pure-JavaScript library that creates and modifies PDF documents in any runtime that supports ES6 — browsers, Node.js, Deno, and React Native. There is no native binary to compile, no headless Chrome to spin up, and no server round-trip required. Everything happens in memory, which makes it a natural fit for privacy-conscious web apps where user files should never leave the device.
The library solves problems that used to require a backend. A few concrete scenarios where teams reach for pdf-lib:
- Invoice and receipt generation — SaaS dashboards build downloadable PDFs from live data without calling a server endpoint
- Contract signing flows — e-signature tools stamp signature images onto existing contract templates and re-save the file
- Document assembly — legal-tech apps merge exhibits, cover pages, and attachments into a single filing bundle
- Form filling — HR tools pre-populate AcroForm fields (W-4, onboarding packets) from a user profile
This guide walks through installation, the core API surface, and the gotchas that tend to bite first-time users. Code samples target the current stable release.
Installation and Setup
Install from npm for bundler-based projects (Vite, Webpack, Next.js, etc.):
npm install pdf-lib
# or
yarn add pdf-lib
# or
pnpm add pdf-libFor a quick prototype or a plain HTML page, load the UMD bundle from a CDN:
<script src="https://unpkg.com/[email protected]/dist/pdf-lib.min.js"></script>
<script>
// The global `PDFLib` namespace is now available
const { PDFDocument, StandardFonts, rgb } = PDFLib;
</script>Minimal bootstrap to confirm the library loads and can generate bytes:
import { PDFDocument } from 'pdf-lib';
async function sanityCheck() {
// Create an empty document and serialize it to Uint8Array
const doc = await PDFDocument.create();
const bytes = await doc.save();
console.log('PDF size (bytes):', bytes.length);
}
sanityCheck();If that logs a non-zero number, you are ready to go. pdf-lib has zero runtime dependencies outside standard browser APIs.
Core Features
1. Creating a New PDF Document
Every workflow starts with PDFDocument.create(). The returned document is empty — you add pages, draw content, and call save() at the end.
import { PDFDocument, rgb } from 'pdf-lib';
async function createHelloPdf() {
const pdfDoc = await PDFDocument.create();
// A4 portrait dimensions in PDF points (1 pt = 1/72 inch)
const page = pdfDoc.addPage([595.28, 841.89]);
// Coordinates start at the bottom-left corner
page.drawText('Hello, pdf-lib!', {
x: 50,
y: 750,
size: 30,
color: rgb(0.1, 0.2, 0.5),
});
return await pdfDoc.save(); // Uint8Array
}Keep the coordinate system in mind. PDF origin sits at the bottom-left, not the top-left. Your brain will fight this for the first hour.
2. Adding Pages and Text
Pages accept a size tuple or a named preset through the PageSizes constant. Text wrapping is manual — pdf-lib does not flow text, so you split strings yourself.
import { PDFDocument, PageSizes, StandardFonts, rgb } from 'pdf-lib';
async function multiPageDoc() {
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Letter size via preset
const page1 = pdfDoc.addPage(PageSizes.Letter);
const { width, height } = page1.getSize();
page1.drawText('Cover Page', {
x: 50,
y: height - 100,
size: 36,
font,
});
// Second page, simple paragraph wrapping at ~80 chars
const page2 = pdfDoc.addPage(PageSizes.Letter);
const paragraph = 'pdf-lib runs in any JS runtime. It does not call out to a server.';
page2.drawText(paragraph, {
x: 50,
y: height - 80,
size: 12,
font,
maxWidth: width - 100, // wraps automatically inside this width
lineHeight: 16,
});
return pdfDoc.save();
}The maxWidth option does handle wrapping for single drawText calls, as of 1.17.x. For precise layout control, measure each line with font.widthOfTextAtSize(text, size) and position manually.
3. Embedding Fonts (Standard and Custom)
PDF has 14 standard fonts that every reader supports. For anything beyond basic Latin text — CJK, emoji, custom branding — you embed a TTF or OTF file.
import { PDFDocument, StandardFonts } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
async function embedCustomFont() {
const pdfDoc = await PDFDocument.create();
// Standard fonts: no extra download needed
const helv = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Custom font: register fontkit first, then fetch bytes
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fetch('/fonts/NotoSansKR-Regular.ttf')
.then((r) => r.arrayBuffer());
const noto = await pdfDoc.embedFont(fontBytes, { subset: true });
const page = pdfDoc.addPage();
page.drawText('English heading', { x: 50, y: 750, font: helv, size: 24 });
page.drawText('한글 본문 텍스트', { x: 50, y: 700, font: noto, size: 18 });
return pdfDoc.save();
}The subset: true flag tells pdf-lib to embed only the glyphs actually used in the document. A full Korean font file runs 4-8 MB; subsetting typically trims that to 30-60 KB.
4. Drawing Shapes and Images
The drawing API covers rectangles, circles, ellipses, lines, and SVG paths. Images come in via embedPng or embedJpg.
import { PDFDocument, rgb } from 'pdf-lib';
async function shapesAndImages(pngBytes) {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([600, 400]);
// Filled rectangle used as a header background
page.drawRectangle({
x: 0,
y: 350,
width: 600,
height: 50,
color: rgb(0.2, 0.4, 0.8),
});
// Outlined circle
page.drawCircle({
x: 100,
y: 200,
size: 50,
borderWidth: 2,
borderColor: rgb(0, 0, 0),
});
// Embed a PNG logo and scale it
const png = await pdfDoc.embedPng(pngBytes);
const scaled = png.scale(0.5);
page.drawImage(png, {
x: 400,
y: 100,
width: scaled.width,
height: scaled.height,
});
return pdfDoc.save();
}5. Modifying Existing PDFs
Loading an existing file uses PDFDocument.load(). Once loaded, pages behave the same as freshly created ones.
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
async function stampExisting(existingBytes) {
const pdfDoc = await PDFDocument.load(existingBytes);
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
pages.forEach((page, idx) => {
const { width } = page.getSize();
// Draw 'DRAFT' watermark on every page
page.drawText('DRAFT', {
x: width / 2 - 60,
y: 30,
size: 20,
font,
color: rgb(0.8, 0, 0),
opacity: 0.5,
});
// Page number at top-right
page.drawText(`Page ${idx + 1}`, {
x: width - 80,
y: page.getHeight() - 30,
size: 10,
font,
});
});
return pdfDoc.save();
}Encrypted PDFs need PDFDocument.load(bytes, { password: 'secret' }) or they throw an EncryptedPDFError.
6. Merging Multiple PDFs
Merging uses copyPages() — pages are cloned from source documents into a destination. The source documents stay untouched.
import { PDFDocument } from 'pdf-lib';
async function mergePdfs(fileBytesArray) {
const merged = await PDFDocument.create();
for (const bytes of fileBytesArray) {
const src = await PDFDocument.load(bytes);
// Copy every page from this source
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
}
return merged.save();
}
// Usage with File inputs from a <input type="file" multiple>
async function handleFiles(fileList) {
const buffers = await Promise.all(
Array.from(fileList).map((f) => f.arrayBuffer())
);
const outBytes = await mergePdfs(buffers);
const blob = new Blob([outBytes], { type: 'application/pdf' });
window.open(URL.createObjectURL(blob));
}7. Adding Metadata
Document metadata — title, author, keywords — lives on the PDFDocument itself through setter methods.
const pdfDoc = await PDFDocument.create();
pdfDoc.setTitle('Q4 Financial Report');
pdfDoc.setAuthor('Finance Team');
pdfDoc.setSubject('Quarterly earnings summary');
pdfDoc.setKeywords(['finance', 'q4', '2025', 'earnings']);
pdfDoc.setProducer('pdf-lib');
pdfDoc.setCreator('internal-report-generator');
pdfDoc.setCreationDate(new Date());
pdfDoc.setModificationDate(new Date());Search engines and document management systems read these fields, so filling them out pays off.
Common Pitfalls
A few traps catch almost everyone on the first real project.
- Non-Latin characters render as question marks. Standard fonts (Helvetica, Times-Roman, Courier) only cover WinAnsi encoding. Drawing Korean, Japanese, Chinese, Arabic, or emoji against those fonts silently falls back to
?. Fix: register fontkit and embed a Unicode font like Noto Sans CJK. - Large files freeze the tab. pdf-lib loads the entire document into memory. A 200-page scanned PDF with embedded images can easily hit 300+ MB of RAM. For documents above ~50 MB, process in a Web Worker so the main thread stays responsive, or consider server-side tooling.
- Forgetting that almost everything is async.
create(),load(),embedFont(),embedPng(), andsave()all return Promises. Chaining them withoutawaitgives you a Promise object instead of the value, which then silently fails further down. Use async/await consistently. - Mixing up coordinate origin. Y increases upward from the bottom of the page. Calculate text position as
page.getHeight() - topMarginwhen translating from top-down design mocks. - TypeScript strictness on embed options. The
embedFontsecond parameter is typed asEmbedFontOptions. Passing a plain object with a typo (subset: truespelled assubSet) compiles fine at runtime but produces a full, unsubsetted font. Usesatisfies EmbedFontOptionsor rely on editor autocompletion.
Alternatives Comparison
pdf-lib is not the only game in town. Each library has different strengths:
| Library | Runtime | Strength | Weakness |
|---|---|---|---|
| pdf-lib | Browser + Node | Modifies existing PDFs, pure JS, no deps | Manual text layout, no HTML rendering |
| jsPDF | Browser + Node | Simple API, html2canvas integration | Cannot edit existing PDFs reliably |
| PDFKit | Node (browser via bundling) | Rich drawing API, vector-first | Creation only, no modification |
| Puppeteer / Playwright | Node + headless Chrome | Renders HTML/CSS to PDF perfectly | Heavy binary, server-only, slow cold start |
Pick pdf-lib when you need to load an existing PDF and change it. Pick jsPDF when you are generating a simple PDF from an HTML page on the client. Pick Puppeteer when fidelity to a complex CSS layout matters more than bundle size.
References
The official sources listed at the top of this article are the best reference as the library evolves. The GitHub README covers edge cases like form filling and page rotation that are out of scope here. The docs site ships runnable examples you can paste into a sandbox.