Skip to content

Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.

The Problem

A generated PDF is empty:

import { PDFDocument } from 'pdf-lib';

const pdf = await PDFDocument.create();
const page = pdf.addPage();
const bytes = await pdf.save();
// Opens but shows a blank page

Or text renders as squares or missing characters:

page.drawText('Hello こんにちは', { x: 50, y: 500, size: 20 });
// "Hello" renders fine, Japanese characters are squares

Or modifying an existing PDF loses its content:

const existingPdf = await PDFDocument.load(pdfBytes);
// Loaded PDF shows blank pages

Why This Happens

pdf-lib is a JavaScript library for creating and modifying PDFs. Unlike server-side tools (wkhtmltopdf, Puppeteer), it generates PDF structures directly:

  • Pages are blank by defaultaddPage() creates an empty page. You must explicitly draw text, images, or shapes. Nothing appears automatically.
  • Only standard 14 fonts are built-in — Times Roman, Helvetica, and Courier (with bold/italic variants). For non-Latin characters (CJK, Arabic, emoji), you must embed a custom font. Unembedded characters render as squares or tofu.
  • Content position uses bottom-left origin — PDF coordinates start at the bottom-left corner (not top-left like HTML). y: 0 is the bottom of the page. This is the opposite of what most developers expect.
  • pdf-lib works in both browser and Node.js — it’s pure JavaScript with no native dependencies. But loading files differs between environments (fetch vs fs.readFile).

A second class of failures comes from the loader. PDFDocument.load accepts Uint8Array, ArrayBuffer, Buffer, and base64 strings — but each environment hands you the bytes in a slightly different shape. In a Next.js API route, req.body is a ReadableStream and you must drain it into a buffer first. In the browser, await file.arrayBuffer() returns an ArrayBuffer which you usually convert to Uint8Array. Mixing types (passing a Blob or a string path) silently fails with cryptic errors.

A third class is the page-content model. pdf-lib writes draw operations into a content stream. If you create a page, then call pdf.save() immediately, the page has an empty content stream and renders blank — even though it exists in the file. Forms behave the same way: filling a field does not “draw” the value onto the page until you call form.flatten() or until the PDF viewer renders the field. Some viewers (Chrome’s built-in, mobile Safari) interpret form fields differently, so a PDF that looks fine in Acrobat may look broken elsewhere.

Version History (pdf-lib 1.x stable era, ESM, and the alternatives)

pdf-lib’s API has been remarkably stable since its 1.x line shipped, but the surrounding ecosystem has shifted enough that the version of Node, fontkit, and your bundler matters.

  • pdf-lib 1.0 stable (2019) — Andrew Dillon’s pdf-lib reached 1.x as the canonical pure-JS PDF library, replacing older options like pdfkit (which only created PDFs, never edited them) and hummus.js (native module, painful on Windows).
  • pdf-lib 1.4 + @pdf-lib/fontkit (2020) — fontkit was split into a peer dependency to keep the core bundle small. If your project uses non-Latin scripts and you forget to registerFontkit(fontkit), font embedding throws “Fontkit is not registered” — still the most common Stack Overflow question about pdf-lib.
  • pdf-lib 1.10-1.17 (2021-2022) — incremental improvements: better form support, PDF/A compliance flags, improved image embedding, bug fixes around incremental updates. No breaking changes.
  • ESM support (2022) — the library shipped proper ESM exports. Many older require('pdf-lib') examples still work via the CJS build, but bundlers that prefer ESM (Vite, modern Webpack, esbuild) now use the ESM path. Mixing CJS and ESM imports in TypeScript with moduleResolution: node16 is the most common build failure.
  • TypeScript types improvement (2022→) — better generics on getForm(), embedFont, and copyPages. Older codebases with @types/pdf-lib should remove the separate types package; pdf-lib ships its own.
  • pdfme (2022→) as an alternative — pdfme wraps pdf-lib and adds a template-based API for filling PDFs from designer-style schemas. If you generate hundreds of variations of the same invoice, pdfme can be more productive than pdf-lib’s imperative API.
  • jsPDF migration considerations (ongoing) — jsPDF is older and uses a turtle-graphics-style draw API. Migrating from jsPDF to pdf-lib usually means rewriting layout code, but pdf-lib’s coordinate system (PDF-native, bottom-left origin) and font handling are more accurate for typography-heavy documents.
  • No major version after 1.x — pdf-lib has not shipped a 2.0. The maintainer prioritized stability over churn, so examples from 2020 still work in 2026 with minimal changes.

Practical implication: if you are starting in 2026, install the latest pdf-lib 1.x, ensure your bundler treats it as ESM, and add @pdf-lib/fontkit only if you need non-standard fonts. If you are evaluating alternatives, consider pdfme for template-driven PDFs and React PDF for React-style declarative layout.

Fix 1: Create a PDF from Scratch

npm install pdf-lib
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';

async function createPDF() {
  const pdf = await PDFDocument.create();

  // Embed a standard font
  const font = await pdf.embedFont(StandardFonts.Helvetica);
  const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold);

  // Add a page (A4 size by default, or specify)
  const page = pdf.addPage([595.28, 841.89]);  // A4 in points
  const { width, height } = page.getSize();

  // Draw text — y coordinate is from BOTTOM
  page.drawText('Invoice', {
    x: 50,
    y: height - 80,  // 80 points from top
    size: 28,
    font: boldFont,
    color: rgb(0.1, 0.1, 0.2),
  });

  page.drawText('Invoice #INV-001', {
    x: 50,
    y: height - 120,
    size: 12,
    font: font,
    color: rgb(0.4, 0.4, 0.4),
  });

  page.drawText('Date: March 29, 2026', {
    x: 50,
    y: height - 140,
    size: 12,
    font: font,
    color: rgb(0.4, 0.4, 0.4),
  });

  // Draw a line
  page.drawLine({
    start: { x: 50, y: height - 160 },
    end: { x: width - 50, y: height - 160 },
    thickness: 1,
    color: rgb(0.8, 0.8, 0.8),
  });

  // Table-like layout
  const items = [
    { description: 'Web Development', qty: 40, rate: 150 },
    { description: 'Design Services', qty: 20, rate: 120 },
    { description: 'Consulting', qty: 10, rate: 200 },
  ];

  let yPosition = height - 200;

  // Table header
  page.drawText('Description', { x: 50, y: yPosition, size: 10, font: boldFont });
  page.drawText('Qty', { x: 300, y: yPosition, size: 10, font: boldFont });
  page.drawText('Rate', { x: 380, y: yPosition, size: 10, font: boldFont });
  page.drawText('Total', { x: 460, y: yPosition, size: 10, font: boldFont });

  yPosition -= 20;

  for (const item of items) {
    page.drawText(item.description, { x: 50, y: yPosition, size: 10, font });
    page.drawText(String(item.qty), { x: 300, y: yPosition, size: 10, font });
    page.drawText(`$${item.rate}`, { x: 380, y: yPosition, size: 10, font });
    page.drawText(`$${item.qty * item.rate}`, { x: 460, y: yPosition, size: 10, font });
    yPosition -= 18;
  }

  // Total
  yPosition -= 10;
  page.drawLine({
    start: { x: 380, y: yPosition + 5 },
    end: { x: width - 50, y: yPosition + 5 },
    thickness: 1,
    color: rgb(0, 0, 0),
  });

  const total = items.reduce((sum, i) => sum + i.qty * i.rate, 0);
  page.drawText(`Total: $${total}`, {
    x: 380,
    y: yPosition - 15,
    size: 14,
    font: boldFont,
  });

  // Draw a rectangle (colored box)
  page.drawRectangle({
    x: 50,
    y: 50,
    width: width - 100,
    height: 40,
    color: rgb(0.95, 0.95, 0.95),
    borderColor: rgb(0.8, 0.8, 0.8),
    borderWidth: 1,
  });

  page.drawText('Thank you for your business!', {
    x: 50 + 10,
    y: 65,
    size: 10,
    font,
    color: rgb(0.5, 0.5, 0.5),
  });

  // Save
  const pdfBytes = await pdf.save();
  return pdfBytes;
}

// Node.js — save to file
import fs from 'fs';
const bytes = await createPDF();
fs.writeFileSync('invoice.pdf', bytes);

// Browser — download
const bytes = await createPDF();
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'invoice.pdf';
a.click();
URL.revokeObjectURL(url);

// API route — return as response
export async function GET() {
  const bytes = await createPDF();
  return new Response(bytes, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="invoice.pdf"',
    },
  });
}

Fix 2: Embed Custom Fonts (Non-Latin Characters)

import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import fs from 'fs';

async function createWithCustomFont() {
  const pdf = await PDFDocument.create();

  // Register fontkit for custom font support
  pdf.registerFontkit(fontkit);

  // Embed a custom font (TTF or OTF)
  const fontBytes = fs.readFileSync('fonts/NotoSansJP-Regular.ttf');
  const customFont = await pdf.embedFont(fontBytes);

  const page = pdf.addPage();
  const { height } = page.getSize();

  // Now CJK characters render correctly
  page.drawText('Hello こんにちは 你好 안녕하세요', {
    x: 50,
    y: height - 80,
    size: 20,
    font: customFont,
  });

  return pdf.save();
}

Fix 3: Embed Images

import { PDFDocument } from 'pdf-lib';

async function addImageToPDF() {
  const pdf = await PDFDocument.create();
  const page = pdf.addPage();
  const { width, height } = page.getSize();

  // Embed PNG
  const pngBytes = await fetch('/logo.png').then(r => r.arrayBuffer());
  const pngImage = await pdf.embedPng(pngBytes);

  // Embed JPEG
  const jpgBytes = await fetch('/photo.jpg').then(r => r.arrayBuffer());
  const jpgImage = await pdf.embedJpg(jpgBytes);

  // Draw image — scaled to fit
  const pngDims = pngImage.scale(0.5);  // Scale to 50%
  page.drawImage(pngImage, {
    x: 50,
    y: height - pngDims.height - 50,
    width: pngDims.width,
    height: pngDims.height,
  });

  // Draw image with custom size
  page.drawImage(jpgImage, {
    x: 50,
    y: height - 400,
    width: 200,
    height: 150,
  });

  return pdf.save();
}

Fix 4: Modify Existing PDFs

import { PDFDocument, rgb } from 'pdf-lib';

// Add watermark to existing PDF
async function addWatermark(pdfBytes: Uint8Array, text: string) {
  const pdf = await PDFDocument.load(pdfBytes);
  const font = await pdf.embedFont('Helvetica');
  const pages = pdf.getPages();

  for (const page of pages) {
    const { width, height } = page.getSize();

    page.drawText(text, {
      x: width / 4,
      y: height / 2,
      size: 50,
      font,
      color: rgb(0.8, 0.8, 0.8),
      opacity: 0.3,
      rotate: degrees(45),
    });
  }

  return pdf.save();
}

// Merge multiple PDFs
async function mergePDFs(pdfBytesArray: Uint8Array[]) {
  const merged = await PDFDocument.create();

  for (const pdfBytes of pdfBytesArray) {
    const pdf = await PDFDocument.load(pdfBytes);
    const pages = await merged.copyPages(pdf, pdf.getPageIndices());
    pages.forEach(page => merged.addPage(page));
  }

  return merged.save();
}

// Extract specific pages
async function extractPages(pdfBytes: Uint8Array, pageNumbers: number[]) {
  const source = await PDFDocument.load(pdfBytes);
  const extracted = await PDFDocument.create();

  const pages = await extracted.copyPages(source, pageNumbers.map(n => n - 1));
  pages.forEach(page => extracted.addPage(page));

  return extracted.save();
}

Fix 5: Fill PDF Forms

import { PDFDocument } from 'pdf-lib';

async function fillForm(templateBytes: Uint8Array, data: Record<string, string>) {
  const pdf = await PDFDocument.load(templateBytes);
  const form = pdf.getForm();

  // Fill text fields
  for (const [fieldName, value] of Object.entries(data)) {
    try {
      const field = form.getTextField(fieldName);
      field.setText(value);
    } catch {
      console.warn(`Field "${fieldName}" not found in PDF`);
    }
  }

  // Fill checkboxes
  const agreeField = form.getCheckBox('agree_terms');
  agreeField.check();

  // Fill dropdowns
  const countryField = form.getDropdown('country');
  countryField.select('United States');

  // Flatten form (make fields non-editable)
  form.flatten();

  return pdf.save();
}

// List all form fields
async function listFormFields(pdfBytes: Uint8Array) {
  const pdf = await PDFDocument.load(pdfBytes);
  const form = pdf.getForm();
  const fields = form.getFields();

  return fields.map(field => ({
    name: field.getName(),
    type: field.constructor.name,
  }));
}
import { PDFDocument } from 'pdf-lib';

async function addMetadata(pdfBytes: Uint8Array) {
  const pdf = await PDFDocument.load(pdfBytes);

  // Set metadata
  pdf.setTitle('My Document');
  pdf.setAuthor('John Doe');
  pdf.setSubject('Invoice');
  pdf.setKeywords(['invoice', 'billing', '2026']);
  pdf.setCreator('My App');
  pdf.setProducer('pdf-lib');
  pdf.setCreationDate(new Date());
  pdf.setModificationDate(new Date());

  return pdf.save();
}

// Password protection (basic)
// Note: pdf-lib doesn't support encryption natively
// Use a library like node-qpdf for encryption after generation

Still Not Working?

PDF is blank — you added a page but didn’t draw anything on it. addPage() creates an empty page. You must call page.drawText(), page.drawImage(), or other draw methods to add visible content.

Text appears at the bottom instead of the top — PDF coordinates use bottom-left as origin. y: 0 is the bottom. For top-left positioning, use y: height - offsetFromTop where height is from page.getSize().

Non-Latin characters show as squares — embed a custom font that supports the characters. Standard fonts (Helvetica, Times, Courier) only support basic Latin. Install @pdf-lib/fontkit and embed a TTF/OTF font with the required character set.

Loading an existing PDF fails — the PDF might be encrypted or use features pdf-lib doesn’t support. Try PDFDocument.load(bytes, { ignoreEncryption: true }). For heavily protected PDFs, you may need to decrypt them first with another tool.

ESM/CJS interop errors during build — Vite, Next.js, and modern bundlers prefer ESM. If you mix import pdfLib from 'pdf-lib' with require() style imports, TypeScript with moduleResolution: node16 complains. Use named imports (import { PDFDocument } from 'pdf-lib') consistently and remove any @types/pdf-lib package since pdf-lib ships its own types.

Form fields filled but the value is invisible in some viewers — Chrome’s PDF viewer and mobile Safari render form fields differently than Acrobat. Call form.flatten() before saving so the field values are drawn directly into the page content. Flattened forms cannot be edited but they render identically everywhere.

Generated PDF is huge after embedding images — pdf-lib does not recompress images. A 5 MB JPEG embedded directly produces a 5 MB section of the PDF. Pre-process images with sharp to resize and compress before embedding, especially for invoices and receipts where 200 KB is usually enough.

For related PDF and document issues, see Fix: React PDF Not Working and Fix: MDX Not Working. For image preprocessing before embedding, see Fix: Sharp Not Working. For server-side PDF generation in Next.js API routes, see Fix: Hono Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles