ScreenshotNeo

BlogHTML to image & PDF

How to Create and Edit PDFs with pdf-lib in JavaScript

Learn how to create, modify, merge, fill, and save PDFs with pdf-lib in JavaScript, including pages, images, forms, fonts, and troubleshooting.

By the ScreenshotNeo team1 October 202610 min read

Direct answer: use PDFDocument.create() for a new PDF, PDFDocument.load(bytes) for an existing file, make changes through the document, page, image, font, or form APIs, then call save() to get PDF bytes. The same lifecycle works in Node.js, browsers, Deno, and React Native, subject to the runtime and package version you choose. The pdf-lib project describes the library as a way to create and modify PDF documents in JavaScript environments.

This guide covers the complete workflow: installation, creating documents, editing existing files, page operations, drawing, images, forms, custom fonts, browser downloads, merging and splitting, troubleshooting, and operational considerations. “Edit” means the operations documented by pdf-lib; it does not promise arbitrary in-place rewriting of every text run or layout object in a complex PDF.

1. Install pdf-lib

Install the package from npm:

npm install --save pdf-lib

Or with Yarn:

yarn add pdf-lib

For browser use, the project also publishes UMD builds through CDNs. Pin a specific version in production CDN URLs, as recommended by the project site, and verify the version against the current documentation before deploying.

2. Create a PDF from scratch

The smallest complete Node.js example creates one page, draws text, serializes the document, and writes the bytes to disk.

import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { writeFile } from 'node:fs/promises';

const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595.28, 841.89]); // A4 points
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);

page.drawText('Hello from pdf-lib', {
  x:  fifty = 50,
  y: 780,
  size: 24,
  font,
  color: rgb(0.1, 0.2, 0.5),
});

const pdfBytes = await pdfDoc.save();
await writeFile('hello.pdf', pdfBytes);

Replace the accidental assignment in the example with a plain numeric value if your formatter rejects it:

page.drawText('Hello from pdf-lib', {
  x: 50,
  y: 780,
  size: 24,
  font,
  color: rgb(0.1, 0.2, 0.5),
});

save() returns the serialized PDF as bytes (a Uint8Array in typical JavaScript usage). In Node, pass those bytes to writeFile. In a browser, turn them into a Blob and download or display them.

3. Load and modify an existing PDF

Read the source bytes, load them, retrieve a page, perform a supported operation, and save a new byte array.

import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { readFile, writeFile } from 'node:fs/promises';

const existingPdfBytes = await readFile('input.pdf');
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();

if (pages.length === 0) {
  throw new Error('The input PDF has no pages');
}

const firstPage = pages[0];
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const { width, height } = firstPage.getSize();

firstPage.drawText('Reviewed', {
  x: 40,
  y: height - 50,
  size: 14,
  font,
  color: rgb(0.8, 0, 0),
});

const outputBytes = await pdfDoc.save();
await writeFile('output.pdf', outputBytes);

This pattern adds new drawing content. It should not be described as unrestricted editing of arbitrary existing text, paragraphs, or layout structures. For those requirements, inspect the current API and validate representative files.

4. Work with pages

Add, insert, and remove pages

const newPage = pdfDoc.addPage([612, 792]); // Letter
const inserted = pdfDoc.insertPage(0, [612, 792]);

const pages = pdfDoc.getPages();
pdfDoc.removePage(pages.length - 1);

Page dimensions are points (1/72 inch). You can pass a width and height array or use a page size from an existing page.

Copy pages between documents

import { PDFDocument } from 'pdf-lib';
import { readFile, writeFile } from 'node:fs/promises';

const source = await PDFDocument.load(await readFile('source.pdf'));
const target = await PDFDocument.create();
const copiedPages = await target.copyPages(source, [0, 2]);
for (const page of copiedPages) target.addPage(page);
await writeFile('selected-pages.pdf', await target.save());

Merge or split PDFs

Merge by loading each source, copying its pages into a destination document, and saving once. Split by creating one destination document per selected source page.

async function mergePdfFiles(paths) {
  const merged = await PDFDocument.create();
  for (const path of paths) {
    const source = await PDFDocument.load(await readFile(path));
    const pages = await merged.copyPages(source, source.getPageIndices());
    pages.forEach((page) => merged.addPage(page));
  }
  return merged.save();
}

const mergedBytes = await mergePdfFiles(['a.pdf', 'b.pdf']);
await writeFile('merged.pdf', mergedBytes);

5. Draw text, vector graphics, and SVG paths

Pages provide drawing methods for text, lines, rectangles, circles, polygons, and other vector operations. The feature list also documents SVG paths.

page.drawRectangle({
  x: 40,
  y: 650,
  width: 515,
  height: 90,
  color: rgb(0.95, 0.96, 1),
  borderColor: rgb(0.2, 0.3, 0.7),
  borderWidth: 1,
});

page.drawLine({
  start: { x: 40, y: 630 },
  end: { x: 555, y: 630 },
  thickness: 2,
  color: rgb(0.2, 0.3, 0.7),
});

page.drawCircle({
  x: 100,
  y: 550,
  size: 24,
  color: rgb(0.9, 0.4, 0.2),
});

Use the current API reference for detailed SVG path syntax and graphics options.

6. Embed PNG and JPEG images

Read image bytes, embed them in the document, and draw the resulting image on a page.

import { readFile } from 'node:fs/promises';

const imageBytes = await readFile('logo.png');
const image = await pdfDoc.embedPng(imageBytes);
const scale = 0.25;
page.drawImage(image, {
  x: 40,
  y: 500,
  width: image.width * scale,
  height: image.height * scale,
});

Use embedJpg for JPEG data. Keep image dimensions and scaling explicit so a large source image does not unexpectedly dominate the page.

7. Use standard and custom fonts

Standard fonts such as Helvetica, Times Roman, and Courier are available through StandardFonts.

const font = await pdfDoc.embedFont(StandardFonts.TimesRomanBold);
page.drawText('Invoice', { x: 50, y: 740, size: 20, font });

Embedding a custom font requires the separate @pdf-lib/fontkit package, as documented by the project.

npm install --save @pdf-lib/fontkit
import fontkit from '@pdf-lib/fontkit';
import { readFile } from 'node:fs/promises';

pdfDoc.registerFontkit(fontkit);
const fontBytes = await readFile('fonts/MyFont.ttf');
const customFont = await pdfDoc.embedFont(fontBytes);
page.drawText('Custom typeface', { x: 50, y: 700, size: 18, font: customFont });

The npm README documents a specific form appearance caveat: default form appearances use standard Helvetica and support Latin characters. For non-Latin form values, embed a suitable font and update the field appearances. Validate Arabic, CJK, and other scripts with the actual font and PDF viewers you support.

8. Create and fill interactive forms

pdf-lib documents form creation, filling, reading, and flattening. Common field classes include buttons, checkboxes, dropdowns, option lists, radio groups, and text fields.

const form = pdfDoc.getForm();
const nameField = form.createTextField('customer.name');
nameField.setText('Ada Lovelace');
nameField.addToPage(page, { x: 120, y: 680, width: 300, height: 24 });

const subscribed = form.createCheckBox('customer.subscribed');
subscribed.check();
subscribed.addToPage(page, { x: 120, y: 640, width: 16, height: 16 });

const country = form.createDropdown('customer.country');
country.setOptions(['United Kingdom', 'United States']);
country.select('United Kingdom');
country.addToPage(page, { x: 120, y: 600, width: 200, height: 24 });

// Keep fields interactive, or flatten them into page content:
// form.flatten();

const bytes = await pdfDoc.save();

When filling a form loaded from an existing PDF, obtain fields by name:

const form = pdfDoc.getForm();
const field = form.getTextField('customer.name');
field.setText('Grace Hopper');
const output = await pdfDoc.save();

Flatten only when you no longer need interactive controls. Before flattening, confirm that the target viewer and downstream workflow do not require editable fields.

9. Browser workflow: download or display the PDF

In a browser, save the bytes as a Blob:

const bytes = await pdfDoc.save();
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = url;
link.download = 'document.pdf';
link.click();
URL.revokeObjectURL(url);

You can also assign the object URL to an iframe or embed element. The project’s quick-start material shows converting output to a data URI for an iframe; object URLs avoid putting a large PDF directly into a data URI.

10. Choosing the right pdf-lib path

Need Starting point Typical operation
New document PDFDocument.create() Add pages and draw content
Existing document PDFDocument.load(bytes) Draw, inspect, or manipulate supported structures
Reorder or combine pages copyPages, insertPage, removePage Merge, split, or reorder
Interactive fields pdfDoc.getForm() Create, fill, read, or flatten fields
Non-Latin text Register fontkit and embed a font Draw text or update form appearances
Browser output Blob or object URL Download or display bytes

11. Or skip the browser setup

If your input is a web page and you need a PDF or screenshot rather than a programmatically authored PDF, ScreenshotNeo provides a single HTTP endpoint. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo includes full-page capture, PDF paper size and margins, custom CSS and JavaScript, waits, headers, cookies, geolocation, caching, signed links, asynchronous jobs, bulk capture, and usage reporting. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

12. Troubleshooting

“Cannot find module pdf-lib”

Install the dependency in the project where the script runs, check that the package is listed in package.json, and use the import style supported by your module configuration. ESM and CommonJS projects may require different import syntax.

The output file is empty or corrupt

Ensure you await pdfDoc.save() and write the returned bytes directly. Do not stringify the Uint8Array or write an object representation.

Text appears outside the page

PDF coordinates start at the bottom-left. Check the page width and height with page.getSize(), then keep x, y, and drawn dimensions within those bounds.

Non-Latin text is missing

Embed a font containing the required glyphs and register @pdf-lib/fontkit. For form values, also update field appearances with the embedded font. Test the generated file in the PDF viewers used by your users.

A form field cannot be found

Inspect the field names in the source PDF and pass the exact fully qualified name to getTextField, getCheckBox, or another accessor. Names are case-sensitive.

Images are too large

Scale the image when drawing it, and consider resizing or compressing the source before embedding. The PDF still contains the embedded image data even when its displayed dimensions are small.

Existing text did not change

Drawing replacement text over an existing page is different from editing the original text objects. Confirm that the operation you need is supported by the current pdf-lib API; otherwise evaluate a PDF-specific text extraction or rewriting tool and test representative documents.

The browser download works but the server fails

Check the runtime’s module format, file permissions, and binary-safe handling of Uint8Array data. In serverless environments, return the bytes with Content-Type: application/pdf rather than converting them to a text string.

13. Performance, reliability, and cost notes

  • Load and save are whole-document operations, so memory use grows with page count, embedded images, and font data. Process very large files in controlled batches and monitor memory in your runtime.
  • Embed a font or image once and reuse the embedded object across pages.
  • Save once after all changes instead of serializing after every drawing operation.
  • For repeatable output, pin the pdf-lib version and verify generated files with representative viewers and fixtures.
  • Keep original input bytes when you need an audit trail, and write output to a separate path until validation succeeds.
  • pdf-lib itself is a JavaScript dependency; infrastructure cost depends on where your code runs and how much PDF data it processes. The research sources provide no benchmark or compatibility percentage.
  • For web-page capture, ScreenshotNeo bills only clean shots; bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing. Use its cache TTL, asynchronous jobs, and bulk capture options when they match your workload.

14. Validate requirements before production

  • Confirm the current pdf-lib release and API reference for your chosen runtime.
  • Test new PDFs and loaded PDFs separately.
  • Test page insertion, removal, copying, and ordering with multi-page fixtures.
  • Test PNG and JPEG rendering at the largest expected dimensions.
  • Test custom fonts and form appearances for every required script.
  • Check interactive forms before and after flattening.
  • Validate output in the PDF viewers and downstream services that matter to your application.
  • For scanned documents, OCR, signatures, encryption, accessibility, archival conformance, or complex layout preservation, inspect current library documentation and test representative files before committing to an implementation.

15. FAQ

Can pdf-lib run in Node.js and the browser?

The project documents browser, Node, Deno, and React Native environments. Verify the specific runtime, bundler, and package version you deploy.

Does save() return a file path?

No. It returns serialized PDF bytes. Write those bytes to a file, HTTP response, Blob, or other binary destination.

Can pdf-lib edit any existing PDF text?

Its documented model supports drawing and structural operations such as pages and forms. Arbitrary in-place editing of every existing text run or layout element should not be assumed.

How do I handle Arabic or other non-Latin form values?

Embed an appropriate custom font with @pdf-lib/fontkit and update form appearances. The default Helvetica form appearance is documented as Latin-only.

Should I flatten a form?

Flatten when the values should become fixed page content and no longer need to be edited. Keep the fields interactive when users or downstream systems still need them.

How can I turn a web page into a PDF without running a browser?

Use ScreenshotNeo’s capture_pdf MCP tool or its screenshot endpoint with PDF options. Its consent handling and no-charge verdicts are useful when pages contain banners, popups, failed loads, or bot checks.