DEV Community

sunshey
sunshey

Posted on

How I 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: 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.

import { ref } from 'vue'

interface DocumentItem {
  id: string
  name: string
  pageCount: number
  pdfDoc: PDFDocument
}

const documents = ref<DocumentItem[]>([])

async function addFiles(fileList: FileList) {
  for (const file of Array.from(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:

interface PageItem {
  id: string
  docId: string
  pageIndex: number
  thumbnail: string
}

const pages = ref<PageItem[]>([])

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. In production, I use pdfjs-dist for rendering because pdf-lib can 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:

<script setup lang="ts">
import { VueDraggableNext } from 'vue-draggable-next'

const pages = ref<PageItem[]>([])
</script>

<template>
  <VueDraggableNext v-model="pages" item-key="id">
    <div
      v-for="page in pages"
      :key="page.id"
      class="page-card"
    >
      <img :src="page.thumbnail" :alt="`Page ${page.pageIndex + 1}`" />
      <span>Page {{ page.pageIndex + 1 }}</span>
    </div>
  </VueDraggableNext>
</template>
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: Blob, filename: string) {
  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 in a shared mutable state
If a user uploads the same PDF twice, you get two separate PDFDocument instances. That’s fine, but treat each upload as immutable. If you edit one in place, the other won’t reflect it.

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 File object if you need restart
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.

5. PDFs with forms may flatten
If your source PDFs have fillable form fields, copyPages keeps the visual content but the form fields may be flattened in the merged output. Test with your target documents.


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/Merge.vue.


Want More Advanced PDF Features?

If you need OCR, form editing, digital signatures, or batch processing beyond what a browser tool can do, Wondershare PDFelement is a solid desktop option. It keeps everything local and adds professional tools on top.

This post contains affiliate links.


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

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How do you handle large PDF files in the browser without running into performance issues? I've had trouble with this in the past. Would love to hear more about your approach.