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