This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/pdfjs-react-viewer-setup.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. How to set up a custom PDF.js viewer in React

Table of contents

    This guide walks through setting up a complete PDF viewer from scratch using React and pdfjs-dist, giving you full control over rendering, events, and layering for annotations, highlights, and custom toolbars.
    How to set up a custom PDF.js viewer in React
    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 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

    Terminal window
    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:

    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-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 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.

    Austin Nguyen

    Austin Nguyen

    AI Engineer

    When Austin isn’t pulling all-nighters to build new features, he enjoys watching science videos on YouTube and cooking.

    Explore related topics

    Try for free Ready to get started?