DEV Community

sunshey
sunshey

Posted on

How to Merge PDF Files in the Browser (No Server Required)


You have three PDFs: a contract, an appendix, and a signature page. You need one file.

The obvious solution is an online PDF merger. But here's the catch — most of them upload your files to a server first. That contract with confidential terms? Now it's on someone else's cloud.

In this post, I'll show you how to merge PDFs entirely in the browser using JavaScript, with a stack I've been running in production for months.


Why Browser-Side Merging Matters

There are two architectures for online PDF tools:

Architecture How it works Privacy
Server-side Upload → server processes → download ❌ Your file leaves your device
Browser-side File loads in browser tab → JavaScript processes → download ✅ File never leaves your device

For personal photos, server-side is fine. For contracts, NDAs, medical records, or financial statements — browser-side is the only option I'd trust.

The trade-off: Browser memory limits. A 500MB scanned document might crash a tab. For typical documents under 100MB, browser-side works smoothly.


The Stack: pdf-lib

The core library is pdf-lib, a powerful PDF manipulation library that runs in Node.js and browsers with zero polyfills.

npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

What it can do:

  • Create, modify, and merge PDFs
  • Add watermarks, annotations, and form fields
  • Set passwords and permissions
  • Extract and embed fonts

For merging specifically, the API is surprisingly clean.


Basic Merge: Two Lines of Code

Here's the simplest possible merge:

import { PDFDocument } from 'pdf-lib';

async function mergePDFs(files) {
  // Create a new empty PDF
  const mergedPdf = await PDFDocument.create();

  for (const file of files) {
    // Load each uploaded file
    const arrayBuffer = await file.arrayBuffer();
    const pdf = await PDFDocument.load(arrayBuffer);

    // Copy all pages into the merged document
    const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
    copiedPages.forEach(page => mergedPdf.addPage(page));
  }

  // Save and return
  const pdfBytes = await mergedPdf.save();
  return new Blob([pdfBytes], { type: 'application/pdf' });
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. Create a blank PDF document
  2. Loop through each uploaded file
  3. Load it, copy all pages, paste them into the merged doc
  4. Save the result as a downloadable Blob

That's it. No server, no upload, no privacy trade-off.


Adding Drag-and-Drop Reordering

The basic merge appends files in the order they're selected. In real apps, users want to reorder.

Here's how I handle it in sotool:

// State: array of { id, file, name }
const fileList = reactive([]);

// User drags file from index oldIndex to newIndex
function reorderFiles(oldIndex, newIndex) {
  const item = fileList.splice(oldIndex, 1)[0];
  fileList.splice(newIndex, 0, item);
}

// Merge respects the current order
async function mergeInOrder() {
  const mergedPdf = await PDFDocument.create();

  for (const item of fileList) {
    const pdf = await PDFDocument.load(await item.file.arrayBuffer());
    const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
    pages.forEach(page => mergedPdf.addPage(page));
  }

  return mergedPdf.save();
}
Enter fullscreen mode Exit fullscreen mode

The key insight: fileList order determines merge order. The UI layer (Vue/React/vanilla JS) handles drag-and-drop. The merge logic just iterates the array.


Granular Control: Merging Specific Page Ranges

Sometimes you don't want the whole file. Maybe you only need pages 1-5 from a 20-page document.

async function mergeWithRanges(files, ranges) {
  // ranges = [{ fileIndex: 0, pages: [0, 1, 2] }, ...]
  const mergedPdf = await PDFDocument.create();

  for (const range of ranges) {
    const file = files[range.fileIndex];
    const pdf = await PDFDocument.load(await file.arrayBuffer());

    // copyPages accepts specific page indices
    const copiedPages = await mergedPdf.copyPages(pdf, range.pages);
    copiedPages.forEach(page => mergedPdf.addPage(page));
  }

  return mergedPdf.save();
}
Enter fullscreen mode Exit fullscreen mode

Use case: A user uploads a 10-page contract and only wants pages 1, 3, and 7-9.


Performance: Handling Large Files

Browser tabs have memory limits (~1-2GB in Chrome). When users drop massive scanned documents, the tab can freeze.

Two optimizations I use:

1. Incremental Loading (when possible)

For simple merges, pdf-lib loads the entire file into memory. If you only need specific pages, there's no way around it — you need the file structure. But for very large files, warn the user:

const MAX_SIZE_MB = 100;

if (file.size > MAX_SIZE_MB * 1024 * 1024) {
  alert('Files over 100MB may be slow. Consider using desktop software.');
}
Enter fullscreen mode Exit fullscreen mode

2. UI Responsiveness

Merging blocks the main thread. For multiple files, chunk the work:

async function mergeWithProgress(files, onProgress) {
  const mergedPdf = await PDFDocument.create();

  for (let i = 0; i < files.length; i++) {
    const pdf = await PDFDocument.load(await files[i].arrayBuffer());
    const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
    pages.forEach(page => mergedPdf.addPage(page));

    // Yield to UI thread
    onProgress(i + 1, files.length);
    await new Promise(r => setTimeout(r, 0));
  }

  return mergedPdf.save();
}
Enter fullscreen mode Exit fullscreen mode

The setTimeout(..., 0) lets the browser update progress bars and handle user input between files.


The Download Step

Once you have the merged PDF bytes, trigger a download:

function downloadPDF(pdfBytes, filename = 'merged.pdf') {
  const blob = new Blob([pdfBytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();

  URL.revokeObjectURL(url);
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: Use the original filename as a base:

const originalName = files[0].name.replace(/\.pdf$/i, '');
downloadPDF(pdfBytes, `${originalName}-merged.pdf`);
Enter fullscreen mode Exit fullscreen mode

Limitations (Honest Assessment)

Browser-side merging isn't perfect:

Limitation Why Workaround
Memory caps Browser tabs limited to ~1-2GB Warn users, process in batches
Bookmarks lost Merging restructures document tree Acceptable for most use cases
Internal links broken Page references change after merge Rarely an issue for merged contracts
No OCR Would require Tesseract.js Use desktop software for scanned PDFs

For 95% of daily tasks — merging a few contracts, combining report sections, assembling portfolios — these limitations don't matter.


Try the Live Tool

If you want to test browser-based PDF merging without writing code:

👉 en.sotool.top/merge

Features:

  • Drag-and-drop file upload
  • Visual thumbnail reordering
  • Page-level selection (merge only specific pages)
  • Pure browser-side processing
  • Free, no signup

The source code is open source if you want to see how the Vue 3 + pdf-lib integration works in practice.


What's Next?

If you're building browser-based document tools, the next challenges are:

  • Splitting PDFs by page ranges (similar API, reverse operation)
  • Compressing PDFs by downsampling embedded images
  • Watermarking with text or image overlays

All three are doable with pdf-lib in the browser. I'll cover splitting and compression in future posts.


Have you built browser-based PDF tools? How do you handle large files or memory constraints? Let me know in the comments.

Top comments (0)