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 (2)

Collapse
 
francofuji profile image
Francisco Perez

The distinction between "runs in the browser" and "architecture enforces the privacy guarantee" is one that gets collapsed too often. Plenty of client-side tools still exfiltrate data through analytics, error reporting, or telemetry. What makes this genuinely privacy-preserving is that file content never initiates a network request — that's a structural constraint, not a policy statement in a privacy FAQ.

The same line of thinking applies to other categories where users hesitate before committing data to a third-party service. On the email side, uncorreotemporal.com takes a similar ephemeral approach — disposable inboxes that exist only for a specific verification or testing use case and are discarded afterward — which matters when you need real email functionality without permanently tying an address to a form submission or an account you're only using once.

Collapse
 
shakeel_skl_019683a0a1836 profile image
Shakeel Skl

that is very useful and have that in my mind already, this solution will even work offline if you have this saved using PWA in desktop/mobile, so your data will never leave the browser , no third party API envloved