DEV Community

Cover image for Building browser-only PDF tools — merge, split, and canvas builder without touching a server
Shakeel Skl
Shakeel Skl

Posted on

Building browser-only PDF tools — merge, split, and canvas builder without touching a server

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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

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)