For years, manipulating PDF files meant a server-side stack: Python's PyPDF, Java's iText, or a command-line tool like Ghostscript. That made simple tasks โ merging a few files, adding page numbers โ surprisingly heavy to implement. Modern JavaScript has changed that. Today you can perform real PDF surgery directly in the browser, with no backend at all.
In this article we walk through how our PDF Tools merge documents and stamp page numbers entirely on the client side using the excellent pdf-lib library.
Why client-side PDF processing?
- Privacy: contracts, invoices, and legal documents never leave the user's device.
- Cost: no servers to run or scale.
- Speed: no upload/download round trip for large files.
Reading a PDF into memory
The browser's File API gives us an ArrayBuffer, which pdf-libloads into a document object we can edit.
import { PDFDocument } from "pdf-lib";
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);Merging multiple PDFs
To merge, we create a fresh document and copy the pages from each source file in order. The key method is copyPages, which clones pages (including their fonts and images) into the new document.
const merged = await PDFDocument.create();
for (const file of files) {
const src = await PDFDocument.load(await file.arrayBuffer());
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((page) => merged.addPage(page));
}
const bytes = await merged.save(); // Uint8ArrayAdding page numbers
To stamp page numbers, we embed a standard font and draw text onto each page. pdf-libuses a bottom-left origin, so a small y value places the text near the bottom of the page.
import { rgb, StandardFonts } from "pdf-lib";
const font = await pdf.embedFont(StandardFonts.Helvetica);
const pages = pdf.getPages();
pages.forEach((page, i) => {
const { width } = page.getSize();
const text = `Page ${i + 1} of ${pages.length}`;
const textWidth = font.widthOfTextAtSize(text, 10);
page.drawText(text, {
x: (width - textWidth) / 2, // centered
y: 20,
size: 10,
font,
color: rgb(0.4, 0.4, 0.4),
});
});Triggering a download
Finally, we wrap the resulting bytes in a Blob and create an object URL the user can download โ still without any server involvement.
const blob = new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
// set <a href={url} download="output.pdf">Performance tips for large files
- Process files as
ArrayBuffers and avoid holding many copies in memory. - Revoke object URLs with
URL.revokeObjectURLwhen you are done to free memory. - For very large batches, consider a Web Worker to keep the UI responsive.
Frequently asked questions
Does merging reduce PDF quality?
No. Copying pages preserves the original content streams, fonts, and images. There is no re-encoding, so quality is identical to the source files.
Can pdf-lib edit existing text?
It can add new content (text, images, pages) and modify structure, but it is not designed to reflow or rewrite existing text content. For that you would need a more specialized toolchain.