A lawyer friend messaged me at 11 PM last week. He'd sent a 47-page contract to a client. Client signed it, sent it back. Everything looked good until my friend noticed page 12 had a typo in the payment terms — the kind of typo that, if a court ever looked at it, could mean the client owed nothing.
"Do I have to make him resign the whole thing? It took us three weeks to get this signed."
Technically no. You just replace page 12 of the signed PDF. The signatures on the other pages stay intact. Five seconds of pdf-lib code.
This post is the technical writeup. By the end you'll have working browser-side JavaScript that replaces any single page in a PDF with a page from another PDF, no server roundtrip, no system dependencies, no Ghostscript install. Plus the gotchas that aren't obvious until you ship and someone reports a bug.
If you just want the no-code version: we built a free tool for it — upload original, pick page, upload replacement, download. Otherwise, keep reading.
Why this is harder than setPage(12, newPage)
PDFs aren't structured the way most developers expect. There is no array of pages you index into. The page tree is a tree (literally — it can be nested), pages reference resources held in a separate dictionary, fonts and images are deduplicated across pages, and cross-references between pages (links, bookmarks, form fields, named destinations) are stored separately from the page content.
When you "replace page 12," you have to:
- Load the original document into an in-memory representation
- Load the replacement document
- Copy the replacement page's content + all its referenced resources (fonts, images, embedded files) into the original document's resource pool
- Insert the copied page into the original's page tree at index 11 (zero-indexed)
- Remove the original page 12 (now at index 12 because of step 4) from the page tree
- Re-serialize the entire document, updating the cross-reference table
If you skip step 3, the new page will appear blank because its fonts and images are still pointing at the wrong document. If you skip step 5, you'll end up with 48 pages instead of 47. If you skip step 6, the cross-references will be wrong and Adobe Reader will complain on open.
The good news: pdf-lib does all of this for you if you call the right methods in the right order.
The pdf-lib version
import { PDFDocument } from 'pdf-lib';
async function replacePage(originalBytes, replacementBytes, pageIndex) {
// 1. Load both documents
const originalDoc = await PDFDocument.load(originalBytes);
const replacementDoc = await PDFDocument.load(replacementBytes);
// 2. Copy the replacement's first page into the original document
// copyPages handles fonts, images, and all referenced resources
const [copiedPage] = await originalDoc.copyPages(replacementDoc, [0]);
// 3. Insert the copied page at the target position
originalDoc.insertPage(pageIndex, copiedPage);
// 4. Remove the old page (now shifted one position later)
originalDoc.removePage(pageIndex + 1);
// 5. Serialize back to a Uint8Array
return originalDoc.save();
}
Usage from the browser:
const originalFile = document.getElementById('original').files[0];
const replacementFile = document.getElementById('replacement').files[0];
const pageNumber = parseInt(document.getElementById('page').value, 10);
const originalBytes = await originalFile.arrayBuffer();
const replacementBytes = await replacementFile.arrayBuffer();
// pageNumber is 1-indexed (user-facing), pageIndex is 0-indexed
const resultBytes = await replacePage(originalBytes, replacementBytes, pageNumber - 1);
// Trigger download
const blob = new Blob([resultBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'replaced.pdf';
a.click();
URL.revokeObjectURL(url);
That's the entire implementation. About 30 lines including the UI glue. Runs in the browser. No server.
The gotchas
This is the section I always wish API docs included.
Gotcha 1: copyPages is async, and people forget the await
// WRONG — silently fails
const [page] = originalDoc.copyPages(replacementDoc, [0]);
// RIGHT
const [page] = await originalDoc.copyPages(replacementDoc, [0]);
copyPages returns a Promise that resolves to an array of PDFPage objects. Forget the await and you'll try to insertPage with a Promise object instead of a page object — pdf-lib will throw a confusing error about an invalid page type.
Gotcha 2: User-facing page numbers vs PDF page indices
Almost every bug report I've ever seen on a PDF tool comes from this. Users think in 1-indexed numbers ("page 1, page 2, page 3"). PDF page arrays are 0-indexed ("index 0, index 1, index 2").
Worse, the page numbers PRINTED on a document body often differ from the PDF's actual page numbers. A 50-page report with a cover page, TOC, and executive summary might print "Page 1" on what is PDF page 4.
Your UI needs to be crystal clear about which number you're asking for. We label our input field "PDF page number (count from the very first page, including covers)". Users still occasionally get it wrong but at least the documentation is clean.
Gotcha 3: Form fields disappear when their page is removed
PDFs can have interactive form fields (text inputs, checkboxes, signatures). These fields are stored in the document's AcroForm dictionary AND referenced from the pages they appear on.
When you remove a page that had form fields, the page reference is gone but the AcroForm dictionary still has orphan field references. Some readers tolerate this. Adobe Reader sometimes shows a "this form is malformed" warning. pdf-lib doesn't currently clean up orphaned form references automatically — you have to do it yourself if your input PDFs have forms.
Quick check before warning your users:
const form = originalDoc.getForm();
const fields = form.getFields();
const pageToRemove = originalDoc.getPage(pageIndex);
const fieldsOnThisPage = fields.filter((f) =>
f.acroField.getWidgets().some((w) => w.P() === pageToRemove.ref)
);
if (fieldsOnThisPage.length > 0) {
// Warn user that their replacement will lose form fields
}
Gotcha 4: Digital signatures invalidate the entire document
If the original PDF was digitally SIGNED (not just visually — actually cryptographically signed with a Sig field), modifying any byte of the document invalidates the signature. The replaced page works fine, the rest of the document opens fine, but the signature panel in Adobe Reader will show "Signature is invalid — document has been modified after signing."
This is by design — that's literally what digital signatures are for. There is no way around it with pdf-lib (or any other PDF library, because circumventing this would defeat the purpose of digital signatures).
What you CAN do is detect this case before replacing and warn the user:
const signatures = originalDoc.getForm().getFields().filter((f) =>
f.constructor.name === 'PDFSignature'
);
if (signatures.length > 0) {
// Warn: replacing a page will invalidate digital signatures
}
Note: this is for cryptographic digital signatures, not signature images. If someone just pasted an image of their signature onto page 5, replacing page 12 is fine and the image stays untouched.
Gotcha 5: Page sizes don't have to match, but the result might look odd
If the original PDF is Letter (8.5 × 11") and the replacement page is A4 (8.27 × 11.69"), the inserted page will appear at A4 dimensions inside the otherwise-Letter document. PDF readers handle this fine — they just show that one page at a different size. But it might look unprofessional.
If you want to normalize the size, you can scale the replacement to match:
const targetPage = originalDoc.getPage(pageIndex); // the page being replaced
const { width, height } = targetPage.getSize();
copiedPage.setSize(width, height);
Be aware this scales the content too — text might look squashed if the aspect ratios differ.
Gotcha 6: Outlines/bookmarks pointing to the replaced page
PDF outlines (the sidebar tree in Adobe Reader) point to specific destinations — usually a (page, x, y, zoom) tuple. If you replace a page, outlines that pointed TO that page still point to the same coordinates on the new page, which may not be a sensible location anymore.
pdf-lib's outline handling is limited. For most use cases you can leave outlines as-is and the user-visible result is "the bookmark goes to a slightly weird spot on the new page." If outlines are critical to your app, you'll need to walk the outline tree and fix up destinations manually.
Doing it without pdf-lib
If you don't want to ship pdf-lib (it's ~700KB minified), there are a few alternatives:
- PDF.js (Mozilla's library) — read-only. Can render pages but not modify or save them. Wrong tool for this job.
- HummusJS / PDFKit-py / PyPDF2 — server-side. Requires a backend. If you have a backend, you have other options too.
- Roll your own PDF parser — possible but you will lose months. PDFs have spec edge cases that took the pdf-lib team years to handle correctly.
- Use a service like our API — outsource the bytes to someone else's pdf-lib. Same operation, just over HTTP.
For browser-side without a backend, pdf-lib is the only realistic option I've found. The bundle size is fine for a tool page where the user explicitly came to do PDF work — bigger problem if you're integrating this into a 50KB landing page.
Performance notes
For typical document sizes (under 50 pages, files under 10MB):
- Page replace operation: 50-200ms
- The
save()call at the end is the bottleneck — re-serializing the whole document with updated cross-references - Memory: roughly 2-3x the file size in memory during operation (original + replacement + intermediate state)
For larger documents (200+ pages, 50MB+ files), things get slow. pdf-lib doesn't stream — the whole document is parsed into memory and the whole result is serialized at once. For 500MB files, you'll hit browser memory limits.
If you regularly handle large PDFs and need to do many small operations, server-side with a streaming PDF library is the right answer.
The free tool version
If you don't want to ship pdf-lib in your own app, or your users aren't developers, we built this as a free in-browser tool:
Upload the original PDF, pick the page number, upload the replacement, download. Same pdf-lib engine under the hood, no install required. Files never upload to a server — everything runs in the browser.
I use it myself when I receive contracts back with typos on non-signature pages. Same workflow as my lawyer friend.
What I'd love feedback on:
- Has anyone built page-replacement in production with pdf-lib at scale? Curious about memory pressure and any wrappers you wrote for the orphan-form-field problem.
- Anyone using a different browser PDF library I should know about? I keep meaning to evaluate
mupdf-js(the Emscripten port of MuPDF) but haven't found time. - For the form-field gotcha — is there a cleaner way to detect orphaned references than the widget-walking approach in this post?
The code in this post is MIT — fork it on GitHub, ship it, change it. If you build something useful with it, share back.
Top comments (0)