pdf-lib Complete Guide: Create, Modify, and Merge PDFs in the Browser

Step-by-step tutorial for pdf-lib, the pure-JavaScript library for creating and modifying PDFs directly in the browser. Covers installation, core API, font embedding, and common pitfalls.

Sources & References

Tested with pdf-lib v1.17.1

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-lib

For 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(), and save() all return Promises. Chaining them without await gives 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() - topMargin when translating from top-down design mocks.
  • TypeScript strictness on embed options. The embedFont second parameter is typed as EmbedFontOptions. Passing a plain object with a typo (subset: true spelled as subSet) compiles fine at runtime but produces a full, unsubsetted font. Use satisfies EmbedFontOptions or rely on editor autocompletion.

Alternatives Comparison

pdf-lib is not the only game in town. Each library has different strengths:

LibraryRuntimeStrengthWeakness
pdf-libBrowser + NodeModifies existing PDFs, pure JS, no depsManual text layout, no HTML rendering
jsPDFBrowser + NodeSimple API, html2canvas integrationCannot edit existing PDFs reliably
PDFKitNode (browser via bundling)Rich drawing API, vector-firstCreation only, no modification
Puppeteer / PlaywrightNode + headless ChromeRenders HTML/CSS to PDF perfectlyHeavy 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.

Back to Blog