DEV Community

sunshey
sunshey

Posted on • Originally published at smartontools.blogspot.com

How to Merge PDFs in the Browser with Vue 3 and pdf-lib

Merging PDFs on the server is straightforward. Upload the files, run a command-line tool, send the result back. But that means your users’ documents touch your infrastructure — and your infrastructure now needs storage, virus scanning, and deletion queues.

I wanted something simpler for en.sotool.top: pick files in the browser, reorder them visually, and download one merged PDF. No server involved.

Here’s how I built it with Vue 3, pdf-lib, and a little Canvas trickery for thumbnails.


Why Client-Side?

The main reason is privacy. Contracts, invoices, tax documents — users don’t want them on a stranger’s server. Client-side merging also means:

  • No upload bandwidth limits
  • No file size caps from your server
  • No storage to clean up
  • Works offline after the page loads

The trade-off is that very large files are limited by the user’s device RAM. For typical office documents, that’s fine.


The Stack

  • Vue 3 — UI, drag-and-drop state, file handling
  • pdf-lib — Load, copy pages, and save the merged PDF
  • Native File API + DragEvents — File selection and reordering
  • Canvas — Page thumbnails for the visual reorder UI
npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

Loading Multiple PDFs

The first step is reading each uploaded file into an ArrayBuffer, then loading it with pdf-lib.

import { PDFDocument } from 'pdf-lib';

async function loadPdfFile(file) {
  const arrayBuffer = await file.arrayBuffer();
  const pdfDoc = await PDFDocument.load(arrayBuffer);
  return pdfDoc;
}
Enter fullscreen mode Exit fullscreen mode

I keep a reactive list of loaded documents plus metadata: original file name, page count, and an array of page objects for the UI.

const documents = ref([]);

async function addFiles(fileList) {
  for (const file of fileList) {
    const pdfDoc = await loadPdfFile(file);
    documents.value.push({
      id: crypto.randomUUID(),
      name: file.name,
      pageCount: pdfDoc.getPageCount(),
      pdfDoc,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Reordering Pages Visually

The simplest merge just appends all pages in file order. But users often need to rearrange pages — move page 3 before page 1, pull a page from file B into file A, etc.

I flatten every document into a single list of page objects:

const pages = ref([]);

async function buildPageList() {
  pages.value = [];
  for (const doc of documents.value) {
    for (let i = 0; i < doc.pageCount; i++) {
      const thumbnail = await renderThumbnail(doc.pdfDoc, i);
      pages.value.push({
        id: `${doc.id}-${i}`,
        docId: doc.id,
        pageIndex: i,
        thumbnail,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For thumbnails, I render a low-resolution PNG of each page using Canvas:

async function renderThumbnail(pdfDoc, pageIndex, scale = 0.2) {
  const page = pdfDoc.getPages()[pageIndex];
  const { width, height } = page.getSize();

  const canvas = document.createElement('canvas');
  canvas.width = width * scale;
  canvas.height = height * scale;
  const ctx = canvas.getContext('2d');

  // pdf-lib pages can be drawn onto another PDF,
  // but for thumbnails we embed into a temp PDF and render via pdf.js
  // or use a simplified placeholder. In production I use pdf.js for rendering.

  return canvas.toDataURL('image/png');
}
Enter fullscreen mode Exit fullscreen mode

In the actual implementation, I use pdfjs-dist for rendering thumbnails because pdf-lib can create and manipulate PDFs but cannot rasterize them. The thumbnail is just for the UI; the merge itself uses pdf-lib.

The reorder UI is a standard Vue draggable list:

<draggable v-model="pages" item-key="id">
  <template #item="{ element }">
    <div class="page-card">
      <img :src="element.thumbnail" />
      <span>Page {{ element.pageIndex + 1 }}</span>
    </div>
  </template>
</draggable>
Enter fullscreen mode Exit fullscreen mode

Merging the PDFs

Once the user is happy with the order, create a new blank PDF and copy pages from the source documents in the chosen order.

async function mergePdfs() {
  const mergedPdf = await PDFDocument.create();

  for (const page of pages.value) {
    const sourceDoc = documents.value.find(d => d.id === page.docId).pdfDoc;
    const [copiedPage] = await mergedPdf.copyPages(sourceDoc, [page.pageIndex]);
    mergedPdf.addPage(copiedPage);
  }

  const mergedBytes = await mergedPdf.save();
  downloadBlob(new Blob([mergedBytes], { type: 'application/pdf' }), 'merged.pdf');
}
Enter fullscreen mode Exit fullscreen mode

copyPages is the key API. It copies a page from a source document into the target document without re-rendering, so quality is preserved.


Downloading the Result

A small helper triggers the browser download:

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. Don’t load the same file twice
If a user uploads the same PDF twice, you get two separate PDFDocument instances. That’s fine, but if you later edit one in place, the other won’t reflect it. I treat each upload as immutable.

2. Page sizes can differ
One PDF might be Letter, another A4, another landscape slide. pdf-lib preserves each page’s original size. Don’t assume a uniform canvas size when rendering thumbnails.

3. Memory matters
Loading a 50MB scanned PDF into memory creates a large PDFDocument. For heavy usage, offer a "clear all" button so users can free memory between merges.

4. Keep the source bytes if you need re-download
PDFDocument.load() consumes the ArrayBuffer. If you want to let users remove files and start over, keep the original File object around or reload from it.


Try It

The merge tool is live at en.sotool.top/merge.

Free, no signup, and nothing uploads to a server.

Full source code is on GitHub. The merge logic lives in src/views/MergeView.vue and src/composables/usePdfMerge.ts.


Have you built client-side PDF tools? What did you use — pdf-lib, PDF.js, or something else?

Top comments (0)