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
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
}
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,
})
}
}
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,
})
}
}
}
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>
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'
)
}
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)
}
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)
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.