Most PDF processing tools have a fundamental architecture problem: they upload your files to a server to do the work.
This makes sense historically — PDF manipulation used to require server-side processing. But modern browser APIs have made that unnecessary for the majority of document workflows. pdf-lib, pdfjs-dist, and Fabric.js give you everything you need to merge, split, and build PDFs entirely client-side.
Here's how I built three PDF tools without a single file upload.
The core libraries
pdf-lib — JavaScript PDF creation and modification, runs in browser and Node. Handles merging, splitting, page manipulation, metadata. The API is clean:
const { PDFDocument } = await import("pdf-lib");
// Merge
const merged = await PDFDocument.create();
for (const file of files) {
const src = await PDFDocument.load(file.bytes);
const indices = parseRanges(file.ranges, src.getPageCount());
const copied = await merged.copyPages(src, indices);
copied.forEach(page => merged.addPage(page));
}
const bytes = await merged.save();
pdfjs-dist — Mozilla's PDF renderer, runs in browser. Used for generating page thumbnails:
const pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc =
`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(bytes) }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 0.3 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
const thumb = canvas.toDataURL("image/jpeg", 0.7);
jszip — For packaging multiple split PDFs into a single ZIP download:
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (let i = 0; i < pageCount; i++) {
const doc = await PDFDocument.create();
const [page] = await doc.copyPages(src, [i]);
doc.addPage(page);
const bytes = await doc.save();
zip.file(`page-${String(i + 1).padStart(3, "0")}.pdf`, bytes);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
PDF Merger — page range selection
The interesting part of the merger is per-file page range selection. Users can specify "1-3,5,7-9" to select specific pages from each file before merging.
function parseRanges(input: string, pageCount: number): number[] {
if (!input.trim()) return Array.from({ length: pageCount }, (_, i) => i);
const pages = new Set<number>();
const parts = input.split(",").map(s => s.trim());
for (const part of parts) {
if (part.includes("-")) {
const [a, b] = part.split("-").map(Number);
for (let i = a; i <= Math.min(b, pageCount); i++) pages.add(i - 1);
} else {
const n = parseInt(part, 10);
if (n >= 1 && n <= pageCount) pages.add(n - 1);
}
}
return [...pages].sort((a, b) => a - b);
}
With live validation and a page count display so users know exactly what will be included.
PDF Splitter — three modes
Rather than one split mode, I built three:
All pages individually — splits every page into a separate PDF, packages as ZIP. Simple, covers the most common use case.
Custom ranges — named ranges with labels. User creates entries like "Introduction: 1-3", "Chapter 1: 4-15", "Appendix: 16-20". Each becomes a separate PDF. Multiple ranges download as ZIP, single range downloads directly.
Visual page selection — click thumbnails to select specific pages, downloads as single extracted PDF. Most intuitive for non-technical users.
type SplitMode = "all" | "ranges" | "select";
PDF Canvas Builder — the contextual sidebar problem
The canvas builder uses Fabric.js. The interesting UX challenge was the property sidebar: showing all properties all the time creates visual noise and confuses users about what applies to what.
The solution was a contextual sidebar that shows only properties relevant to the selected object type:
- Text selected → font, size, color, weight, style, alignment, line height, rotation
- Shape selected → fill, stroke, stroke width, opacity, rotation, flip H/V
- Image selected → opacity, brightness, contrast, saturation, grayscale, rotation, flip H/V
- Nothing selected → hint card + add new object panels
type ObjType = "text" | "shape" | "image" | null;
function getObjType(obj: any): ObjType {
const t = obj?.type?.toLowerCase() ?? "";
if (["i-text","itext","text","textbox"].includes(t)) return "text";
if (t === "image") return "image";
if (obj) return "shape";
return null;
}
The sidebar re-renders based on selObj.type, showing the right controls for whatever is selected. Image filters (brightness/contrast/saturation/grayscale) use Fabric's built-in filter pipeline.
The SSR problem with canvas-heavy components
Next.js SSR and Fabric.js don't mix. The solution is dynamic import with ssr: false:
// pages/tools/pdf-builder.tsx
const PdfBuilderClient = dynamic(
() => import("@/components/pdf/PdfBuilderClient"),
{ ssr: false }
);
Same pattern for the merger/splitter — any component that touches window, document, or browser-only libraries needs this treatment in Next.js pages router.
Privacy by architecture
The key insight: when you don't build a server, you can't accidentally leak data to it. The privacy guarantee isn't a policy — it's an architectural constraint.
For PDF tools specifically this matters. Documents people merge and split often contain:
- Contracts and NDAs
- Financial statements
- Medical records
- Personal identification
None of those should be uploaded to a third party service just because someone needs to combine two PDFs.
Try them
- PDF Merger — combine PDFs with page range selection
- PDF Splitter — split by pages, ranges, or visual selection
- PDF Canvas Builder — create PDFs from scratch
Open DevTools → Network tab while using any of them. No file upload requests. Everything stays in your browser.
If you're building browser-based file processing tools, the pdf-lib + pdfjs-dist combination covers most PDF use cases without any server infrastructure. Worth knowing about.
Top comments (0)