How to set up a custom PDF.js viewer in React
Table of contents
pdfjs-dist, giving you full control over rendering, events, and layering for annotations, highlights, and custom toolbars.
Build a custom PDF.js viewer in React directly on pdfjs-dist, without a wrapper library. The setup has nine pieces:
- Configure
GlobalWorkerOptions.workerSrcbefore loading any document. - Set
cMapUrlandstandardFontDataUrlfor 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
pagesinitevent. - Store PDF.js objects in
useRef(mutable, external) and expose via React Context. - Import
pdfjs-dist/web/pdf_viewer.cssor 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 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
npm install pdfjs-distStep 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
// 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-distdirectly 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, notuseState— they’re mutable external instances. - Always configure the worker, CMap URLs, and standard font URLs before loading.
- Import
pdfjs-dist/web/pdf_viewer.cssor your text/annotation layers won’t render.
FAQ
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.
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.
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.
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.
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.
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 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:
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 for an alternative to PDF.js, or follow the migration guide to switch. Talk to Sales about your requirements.