---
title: "How to set up a custom PDF.js viewer in React"
canonical_url: "https://www.nutrient.io/blog/pdfjs-react-viewer-setup/"
md_url: "https://www.nutrient.io/blog/pdfjs-react-viewer-setup.md"
last_updated: "2026-06-03T18:57:15.297Z"
description: "Learn how to build a complete PDF.js viewer in React from scratch using pdfjs-dist, including worker configuration, EventBus, FindController, React Context integration, and cleanup."
---

**TL;DR**

Build a custom PDF.js viewer in React directly on `pdfjs-dist`, without a wrapper library. The setup has nine pieces:

- Configure `GlobalWorkerOptions.workerSrc` before loading any document.

- Set `cMapUrl` and `standardFontDataUrl` for Chinese, Japanese, and Korean (CJK) and standard font rendering.

- Wire four core components: `EventBus`, `PDFLinkService`, `PDFFindController`, `PDFViewer`.

- Connect the loaded document to all three (`setDocument`).

- Set initial scale inside the `pagesinit` event.

- Store PDF.js objects in `useRef` (mutable, external) and expose via React Context.

- Import `pdfjs-dist/web/pdf_viewer.css` or the text and annotation layers won’t render.

- Destroy the loading task and document on unmount.

- Call `viewer.update()` to force a refresh after panel resizes.

If you’d rather not wire this up, [Nutrient Web SDK](https://www.nutrient.io/sdk/web-overview/) does the same with `NutrientViewer.load({ container, document })`.

PDF.js ships a full viewer layer (`pdfjs-dist/web/pdf_viewer.mjs`) that most tutorials ignore in favor of wrapper libraries like `react-pdf`. Building directly on PDF.js gives you full control over rendering, events, and layering, which you’ll need for annotations, highlights, and custom toolbars.

## Install

```bash

npm install pdfjs-dist

```

## Step 1: Configure the web worker

PDF.js offloads parsing to a web worker. You must point `GlobalWorkerOptions.workerSrc` to the worker file before loading any document:

```tsx

import { GlobalWorkerOptions, version } from "pdfjs-dist";

GlobalWorkerOptions.workerSrc = new URL(
  "pdfjs-dist/legacy/build/pdf.worker.min.mjs",
  import.meta.url,
).toString();

```

**Why `legacy/build`?** The legacy build includes polyfills for broader browser support. If you only target modern browsers, use `build/pdf.worker.min.mjs` instead.

## Step 2: Set default options

PDF.js needs CMap files for CJK fonts and standard font data for proper rendering. You can serve these from unpkg or bundle them yourself:

```ts

const pdfDefaultOptions = {
  cMapUrl: `https://unpkg.com/pdfjs-dist@${version}/cmaps/`,
  standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${version}/standard_fonts`,
  rangeChunkSize: 1024 * 1024, // 1MB chunks for range requests
};

```

## Step 3: Initialize the core components

PDF.js’s viewer layer has four key components that wire together:

```tsx

const pdfjs = await import("pdfjs-dist/web/pdf_viewer.mjs");

// 1. `EventBus` — the central pub/sub for all PDF.js events.
const eventBus = new pdfjs.EventBus();

// 2. `LinkService` — handles internal/external links in the PDF.
const linkService = new pdfjs.PDFLinkService({
  eventBus,
  externalLinkTarget: pdfjs.LinkTarget.BLANK,
});

// 3. `FindController` — runs text search.
const findController = new pdfjs.PDFFindController({
  linkService,
  eventBus,
});

// 4. `PDFViewer` — the actual rendering engine.
const viewer = new pdfjs.PDFViewer({
  container: document.getElementById("pdf-container"),
  eventBus,
  linkService,
  findController,
});

// Wire the link service back to the viewer.
linkService.setViewer(viewer);

```

## Step 4: Load a document

Create a loading task with `getDocument` and await the document. Then connect it to the link service, find controller, and viewer:

```tsx

import { getDocument } from "pdfjs-dist";

const loadingTask = getDocument({...pdfDefaultOptions,
  url: "https://example.com/document.pdf",
});

const pdfDocument = await loadingTask.promise;

// Connect document to all components.
linkService.setDocument(pdfDocument);
findController.setDocument(pdfDocument);
viewer.setDocument(pdfDocument);

```

## Step 5: Set initial scale on page init

PDF.js fires a `pagesinit` event once all pages are laid out. Set the initial scale here:

```tsx

eventBus.on("pagesinit", () => {
  viewer.currentScaleValue = "auto"; // or "page-width", "page-fit", "1.5"
});

```

## Step 6: Wrap it in React with Context

The key pattern is to store all PDF.js objects in refs (they’re mutable, external instances) and expose them via React Context:

```tsx

import React from "react";

interface PDFContextProps {
  viewer: React.RefObject<PDFViewer | undefined>;
  eventBus: React.RefObject<EventBus | undefined>;
  pdfDocument: React.RefObject<PDFDocumentProxy | undefined>;
  linkService: React.RefObject<PDFLinkService | undefined>;
  findController: React.RefObject<PDFFindController | undefined>;
  setCurrentPageNumber: (page: number) => void;
  setCurrentScaleValue: (scale: string) => void;
  setPagesRotation: (delta: number) => void;
}

export const PDFContext = React.createContext<PDFContextProps | undefined>(undefined);

```

Use `useRef` for the mutable PDF.js objects (they don’t trigger rerenders), and provide setter functions for actions that should update the viewer:

```tsx

function PDFLoader({ children }: { children: React.ReactNode }) {
  const viewer = React.useRef<PDFViewer>();
  const eventBus = React.useRef<EventBus>();
  const pdfDocument = React.useRef<PDFDocumentProxy>();
  const linkService = React.useRef<PDFLinkService>();
  const findController = React.useRef<PDFFindController>();

  //...initialize the refs in a `useEffect` using the code from steps 3–5.

  const contextValue = {
    viewer,
    eventBus,
    pdfDocument,
    linkService,
    findController,
    setCurrentPageNumber: (page: number) => {
      if (viewer.current) viewer.current.currentPageNumber = page;
    },
    setCurrentScaleValue: (scale: string) => {
      if (viewer.current) viewer.current.currentScaleValue = scale;
    },
    setPagesRotation: (delta: number) => {
      if (viewer.current) viewer.current.pagesRotation += delta;
    },
  };

  return <PDFContext.Provider value={contextValue}>{children}</PDFContext.Provider>;
}

```

## Step 7: The container HTML

Render a container element for PDF.js to mount into, with a child `.pdfViewer` element for the pages:

```tsx

import "pdfjs-dist/web/pdf_viewer.css";

function PDFViewerContainer() {
  return (
    <div
      id="pdf-container"
      style={{ position: "absolute", width: "100%", height: "100%" }}
    >
      <div id="viewer" className="pdfViewer" />
    </div>
  );
}

```

You must import `pdfjs-dist/web/pdf_viewer.css` for the text layer, annotation layer, and page layout to work correctly.

## Step 8: Cleanup on unmount

Always destroy the loading task and worker when the component unmounts:

```tsx

React.useEffect(() => {
  return () => {
    if (loadingTask.current &&!loadingTask.current.destroyed) {
      loadingTask.current.destroy();
    }
    pdfDocument.current?.destroy();
  };
}, []);

```

`loadingTask.destroy()` terminates the underlying worker — no need to reach into the private `_worker` field.

## Step 9: Force refresh on resize

If your viewer lives in a resizable panel, the rendered pages may not reflow. Use a custom event to force a rerender:

```tsx

// Register listener.
window.addEventListener("refresh-pdf-viewer", () => {
  viewer.update(); // forces a layout pass.
});

// Dispatch from anywhere (e.g. sidebar toggle).
window.dispatchEvent(new CustomEvent("refresh-pdf-viewer"));

```

## Complete architecture

```

PDFLoader (Context Provider)
  |-- EventBus (pub/sub)
  |-- PDFLinkService (links + navigation)
  |-- PDFFindController (text search)
  |-- PDFViewer (rendering)
  |
  |-- PDFViewerContainer (DOM container)
  |-- SearchToolbar (dispatches find events)
  |-- PageToolbar (page navigation)
  |-- AnnotationSystem (custom overlays)

```

## Key takeaways

- Use `pdfjs-dist` directly instead of wrapper libraries when you need custom annotations, highlights, or toolbars.

- All PDF.js components communicate through `EventBus` — learn the event names.

- Store PDF.js objects in `useRef`, not `useState` — they’re mutable external instances.

- Always configure the worker, CMap URLs, and standard font URLs before loading.

- Import `pdfjs-dist/web/pdf_viewer.css` or your text/annotation layers won’t render.

## FAQ

#### Why use `pdfjs-dist` directly instead of `react-pdf` or `@react-pdf-viewer/core`?

Wrapper libraries are faster to get running but hide the `PDFViewer`, `EventBus`, and `PDFLinkService` APIs you need for custom annotations, highlight overlays, text-layer manipulation, or a custom toolbar. Building on `pdfjs-dist` directly gives you full access to the viewer layer at the cost of more setup code.

#### What’s the difference between `legacy/build` and `build` in `pdfjs-dist`?

`legacy/build` ships transpiled output with polyfills for older browsers. `build` is the modern ES module build with no polyfills. Use `legacy/build` if you need to support Safari < 15.4, Chrome < 95, or any browser that doesn’t support `top-level await` and modern syntax. Use `build` for evergreen browsers only.

#### Why do I need to import `pdf_viewer.css`?

PDF.js’s `PDFViewer` renders three layers per page: the canvas (image), the text layer (selection), and the annotation layer (links and form fields). The CSS positions and stacks these layers correctly. Without it, pages render but text selection breaks and annotations appear in the wrong place.

#### Why use `useRef` instead of `useState` for PDF.js objects?

`PDFViewer`, `EventBus`, and friends are mutable, external instances — assigning a new value doesn’t change their identity, and storing them in `useState` would trigger unnecessary rerenders and stale closures. `useRef` keeps a stable reference across renders without coupling to React’s render cycle.

#### How do I clean up PDF.js properly on unmount?

Call `loadingTask.destroy()` to terminate the worker and abort any in-flight parsing. Then use `pdfDocument.destroy()` to free the document. Wrap both in a `useEffect` cleanup function. The worker is shut down by `loadingTask.destroy()` — you don’t need to reach into the private `_worker` field.

#### How does Nutrient Web SDK compare for this setup?

Nutrient replaces the entire nine-step setup with one function call: `NutrientViewer.load({ container, document })`. No worker configuration, no manual `EventBus`/`LinkService`/`FindController` wiring, no CSS imports, no cleanup code. See the [migration guide](https://www.nutrient.io/guides/web/about/migration-guides/migrating-from-mozilla-pdfjs.md) for switching from PDF.js.

## How Nutrient Web SDK handles this

The entire PDF.js setup above — worker configuration, `EventBus`, `LinkService`, `FindController`, `PDFViewer` wiring, cleanup — is replaced by a single function call:

```js

import NutrientViewer from "@nutrient-sdk/viewer";

NutrientViewer.load({
  container: "#pdf-container",

  document: "document.pdf",
});

```

Nutrient’s WebAssembly-based rendering engine handles text selection, search, annotations, and forms natively — with none of the wiring above.

_See [Nutrient Web SDK](https://www.nutrient.io/sdk/web-overview/) for an alternative to PDF.js, or follow the [migration guide](https://www.nutrient.io/guides/web/about/migration-guides/migrating-from-mozilla-pdfjs.md) to switch. [Talk to Sales](https://www.nutrient.io/contact-sales/?=sdk) about your requirements._
---

## Related pages

- [The business case for accessibility: Five ways it drives enterprise value](/blog/5-ways-accessibility-drives-enterprise-value.md)
- [Accessibility Untangled Why It Matters Guide](/blog/accessibility-untangled-why-it-matters-guide.md)
- [Auto Tagging And Document Accessibility In Dotnet Sdk](/blog/auto-tagging-and-document-accessibility-in-dotnet-sdk.md)
- [Advanced Techniques For React Native Ui Components](/blog/advanced-techniques-for-react-native-ui-components.md)
- [Best Document Viewers](/blog/best-document-viewers.md)
- [Complete Guide To Pdfjs](/blog/complete-guide-to-pdfjs.md)
- [The CEO’s AI playbook: Why decision architecture beats model selection](/blog/ceo-ai-playbook-decision-architecture.md)
- [The CTO’s AI playbook: Why accountability architecture beats orchestration](/blog/cto-ai-playbook-accountability-architecture.md)
- [Digital Signatures](/blog/digital-signatures.md)
- [Document Ai Vs Ocr](/blog/document-ai-vs-ocr.md)
- [Document Viewer](/blog/document-viewer.md)
- [or](/blog/how-to-build-a-javascript-pdf-viewer-with-pdfjs.md)
- [How To Build A Reactjs Viewer With Pdfjs](/blog/how-to-build-a-reactjs-viewer-with-pdfjs.md)
- [or](/blog/how-to-build-a-reactjs-pdf-viewer-with-react-pdf.md)
- [or](/blog/how-to-convert-html-to-pdf-using-wkhtmltopdf-and-python.md)
- [Linearized Pdf](/blog/linearized-pdf.md)
- [base_url tells WeasyPrint where to resolve relative asset paths](/blog/how-to-generate-pdf-reports-from-html-in-python.md)
- [Nutrient Vs Conga Composer](/blog/nutrient-vs-conga-composer.md)
- [Online Document Viewer](/blog/online-document-viewer.md)
- [Open Pdf In Your Web App](/blog/open-pdf-in-your-web-app.md)
- [Pdf Page Labels](/blog/pdf-page-labels.md)
- [Pdf Ua Compliance Guide](/blog/pdf-ua-compliance-guide.md)
- [Pdf Sdk Compliance Security Checklist](/blog/pdf-sdk-compliance-security-checklist.md)
- [Pdf Sdk Performance Benchmark](/blog/pdf-sdk-performance-benchmark.md)
- [Process Flows](/blog/process-flows.md)
- [Pdfjs Limitations Commercial Upgrade](/blog/pdfjs-limitations-commercial-upgrade.md)
- [or](/blog/sample-blog-updated.md)
- [Convert an HTML file to PDF.](/blog/top-ten-ways-to-convert-html-to-pdf.md)
- [What Are Annotations](/blog/what-are-annotations.md)
- [Wcag2 Accessibility Requirements Documents](/blog/wcag2-accessibility-requirements-documents.md)
- [Why Your Ai Agent Hallucinates Pdf Table Data](/blog/why-your-ai-agent-hallucinates-pdf-table-data.md)
- [Vector Pdf](/blog/vector-pdf.md)
- [What Is A Vpat](/blog/what-is-a-vpat.md)
- [What Is Pdf Ua](/blog/what-is-pdf-ua.md)

