How to build a React PDF viewer with react-pdf (2026)

Table of contents

    Build a full-featured React PDF viewer from scratch with react-pdf. This tutorial walks through page navigation, zoom, thumbnails, outline navigation, error handling, and a TypeScript implementation. You’ll also see how Nutrient’s React PDF library compares when you need annotations, forms, or signatures.
    How to build a React PDF viewer with react-pdf (2026)
    TL;DR

    This tutorial builds a React PDF viewer with react-pdf step by step — including page navigation, zoom, thumbnails, outline, error handling, and TypeScript types. It then shows how Nutrient’s React PDF library handles the same task when you need annotations, forms, or signatures out of the box.

    What is react-pdf?

    react-pdf(opens in a new tab) is an open source React component library built on top of Mozilla’s PDF.js(opens in a new tab). It provides Document and Page components that handle PDF rendering, along with Outline and Thumbnail components for navigation. The library has around 950K weekly downloads on npm and is maintained by Wojciech Maj(opens in a new tab).

    react-pdf focuses on rendering — it doesn’t include a prebuilt UI, annotations, form filling, or signatures. That makes it a good fit for read-only viewers where you want full control over the interface.

    Prerequisites

    Before starting, make sure you have:

    Building a PDF viewer with react-pdf

    This section walks through building a PDF viewer from scratch — setting up a React project, configuring the PDF.js worker, rendering pages, and adding features like navigation, zoom, thumbnails, outline support, error handling, and TypeScript types.

    Create a React project

    Scaffold a new React project with Vite:

    Terminal window
    npm create vite@latest react-pdf-demo -- --template react

    Change into the project directory and install dependencies:

    Terminal window
    cd react-pdf-demo
    npm install

    Install react-pdf and configure the worker

    Install the react-pdf package:

    Terminal window
    npm install react-pdf

    Place a PDF file in the public directory. You can use our demo document — rename it to document.pdf.

    react-pdf depends on a PDF.js web worker for rendering. The worker must be configured in the same file where you use the Document component. You’ll set this up in the next step.

    Render a PDF document

    Open src/App.jsx and replace its contents with the following:

    import { useState } from "react";
    import { Document, Page, pdfjs } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    const App = () => {
    const [numPages, setNumPages] = useState(null);
    const [pageNumber, setPageNumber] = useState(1);
    const onDocumentLoadSuccess = ({ numPages }) => {
    setNumPages(numPages);
    };
    const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
    const goToNextPage = () =>
    setPageNumber((prev) => (numPages ? Math.min(prev + 1, numPages) : prev));
    return (
    <div>
    <nav>
    <button onClick={goToPrevPage}>Prev</button>
    <button onClick={goToNextPage}>Next</button>
    <p>
    Page {pageNumber} of {numPages}
    </p>
    </nav>
    <Document file="document.pdf" onLoadSuccess={onDocumentLoadSuccess}>
    <Page pageNumber={pageNumber} />
    </Document>
    </div>
    );
    };
    export default App;

    The worker configuration, text layer CSS, and annotation layer CSS are all included in this single file. Document loads the PDF and exposes numPages through onLoadSuccess. Page renders one page at a time based on pageNumber.

    Start the development server:

    Terminal window
    npm run dev

    You can access the full code on GitHub(opens in a new tab).

    Add page navigation

    The code above already includes basic previous/next buttons. Here’s a more complete navigation bar with a page input field:

    <nav style={{ display: "flex", alignItems: "center", gap: "8px" }}>
    <button onClick={goToPrevPage} disabled={pageNumber <= 1}>
    Prev
    </button>
    <span>
    Page{" "}
    <input
    type="number"
    min={1}
    max={numPages || 1}
    value={pageNumber}
    onChange={(e) => {
    const page = Number(e.target.value);
    if (page >= 1 && page <= numPages) {
    setPageNumber(page);
    }
    }}
    style={{ width: "50px", textAlign: "center" }}
    />{" "}
    of {numPages}
    </span>
    <button onClick={goToNextPage} disabled={!numPages || pageNumber >= numPages}>
    Next
    </button>
    </nav>

    This adds an input field so users can jump directly to a page, and it disables the buttons at the document boundaries.

    Add zoom controls

    The Page component accepts a scale prop that controls the zoom level. Add scale state and zoom buttons:

    import { useState } from "react";
    import { Document, Page, pdfjs } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    const App = () => {
    const [numPages, setNumPages] = useState(null);
    const [pageNumber, setPageNumber] = useState(1);
    const [scale, setScale] = useState(1.0);
    const onDocumentLoadSuccess = ({ numPages }) => {
    setNumPages(numPages);
    };
    const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
    const goToNextPage = () =>
    setPageNumber((prev) => (numPages ? Math.min(prev + 1, numPages) : prev));
    const zoomIn = () => setScale((prev) => Math.min(prev + 0.25, 3));
    const zoomOut = () => setScale((prev) => Math.max(prev - 0.25, 0.5));
    const resetZoom = () => setScale(1.0);
    return (
    <div>
    <nav style={{ display: "flex", gap: "8px", alignItems: "center" }}>
    <button onClick={goToPrevPage} disabled={pageNumber <= 1}>
    Prev
    </button>
    <span>
    Page {pageNumber} of {numPages}
    </span>
    <button onClick={goToNextPage} disabled={!numPages || pageNumber >= numPages}>
    Next
    </button>
    <span>|</span>
    <button onClick={zoomOut} disabled={scale <= 0.5}>
    </button>
    <span>{Math.round(scale * 100)}%</span>
    <button onClick={zoomIn} disabled={scale >= 3}>
    +
    </button>
    <button onClick={resetZoom}>Reset</button>
    </nav>
    <Document file="document.pdf" onLoadSuccess={onDocumentLoadSuccess}>
    <Page pageNumber={pageNumber} scale={scale} />
    </Document>
    </div>
    );
    };
    export default App;

    The scale prop maps directly to the PDF.js render scale. A value of 1 is 100 percent, 1.5 is 150 percent, and so on. The buttons clamp the range between 50 percent and 300 percent.

    Add a thumbnail sidebar

    react-pdf exports a Thumbnail component that renders a small preview of a page. You can use it to build a clickable sidebar:

    import { useState } from "react";
    import { Document, Page, Thumbnail, pdfjs } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    const App = () => {
    const [numPages, setNumPages] = useState(null);
    const [pageNumber, setPageNumber] = useState(1);
    const onDocumentLoadSuccess = ({ numPages }) => {
    setNumPages(numPages);
    };
    return (
    <Document file="document.pdf" onLoadSuccess={onDocumentLoadSuccess}>
    <div style={{ display: "flex", gap: "16px" }}>
    {/* Thumbnail sidebar */}
    <div
    style={{
    width: "150px",
    overflowY: "auto",
    maxHeight: "80vh",
    borderRight: "1px solid #ccc",
    padding: "8px",
    }}
    >
    {numPages &&
    Array.from({ length: numPages }, (_, index) => (
    <div
    key={index}
    onClick={() => setPageNumber(index + 1)}
    style={{
    cursor: "pointer",
    border:
    pageNumber === index + 1
    ? "2px solid #0078d4"
    : "2px solid transparent",
    marginBottom: "8px",
    }}
    >
    <Thumbnail pageNumber={index + 1} width={130} />
    </div>
    ))}
    </div>
    {/* Main page view */}
    <div>
    <Page pageNumber={pageNumber} />
    </div>
    </div>
    </Document>
    );
    };
    export default App;

    The Thumbnail component accepts most of the same props as Page (including width for sizing). Clicking a thumbnail updates the pageNumber state to navigate to that page. The active thumbnail gets a blue border.

    Display the document outline

    If the PDF has bookmarks, the Outline component renders them as a clickable list. Its onItemClick callback receives { pageNumber }, which you can use to navigate:

    import { useState } from "react";
    import { Document, Page, Outline, pdfjs } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    const App = () => {
    const [numPages, setNumPages] = useState(null);
    const [pageNumber, setPageNumber] = useState(1);
    const onDocumentLoadSuccess = ({ numPages }) => {
    setNumPages(numPages);
    };
    return (
    <Document file="document.pdf" onLoadSuccess={onDocumentLoadSuccess}>
    <div style={{ display: "flex", gap: "16px" }}>
    {/* Outline sidebar */}
    <div
    style={{
    width: "200px",
    overflowY: "auto",
    maxHeight: "80vh",
    borderRight: "1px solid #ccc",
    padding: "8px",
    }}
    >
    <h3>Table of Contents</h3>
    <Outline onItemClick={({ pageNumber: pg }) => setPageNumber(pg)} />
    </div>
    {/* Main page view */}
    <div>
    <nav>
    <button
    onClick={() => setPageNumber((p) => Math.max(p - 1, 1))}
    disabled={pageNumber <= 1}
    >
    Prev
    </button>
    <span>
    Page {pageNumber} of {numPages}
    </span>
    <button
    onClick={() =>
    setPageNumber((p) => (numPages ? Math.min(p + 1, numPages) : p))
    }
    disabled={!numPages || pageNumber >= numPages}
    >
    Next
    </button>
    </nav>
    <Page pageNumber={pageNumber} />
    </div>
    </div>
    </Document>
    );
    };
    export default App;

    The Outline component only renders content if the PDF contains bookmarks. For PDFs without an outline, it renders nothing.

    Handle errors and loading states

    The Document and Page components accept loading, error, and noData props for displaying a fallback user interface (UI):

    import { useState } from "react";
    import { Document, Page, pdfjs } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    const App = () => {
    const [numPages, setNumPages] = useState(null);
    const [pageNumber, setPageNumber] = useState(1);
    const [loadError, setLoadError] = useState(null);
    const [retryKey, setRetryKey] = useState(0);
    if (loadError) {
    return (
    <div style={{ padding: "20px", color: "red" }}>
    <h2>Failed to load PDF</h2>
    <p>{loadError.message}</p>
    <button
    onClick={() => {
    setLoadError(null);
    setRetryKey((k) => k + 1);
    }}
    >
    Retry
    </button>
    </div>
    );
    }
    return (
    <Document
    key={retryKey}
    file="document.pdf"
    onLoadSuccess={({ numPages }) => setNumPages(numPages)}
    onLoadError={(error) => setLoadError(error)}
    loading={<div style={{ padding: "20px" }}>Loading PDF…</div>}
    noData={<div style={{ padding: "20px" }}>No PDF file specified.</div>}
    >
    <Page
    pageNumber={pageNumber}
    loading={<div style={{ padding: "20px" }}>Rendering page…</div>}
    />
    </Document>
    );
    };
    export default App;

    The onLoadError callback receives the error object. This example stores it in state and shows a retry button. Incrementing retryKey forces React to remount the Document component, which retries the PDF load. The loading prop displays a placeholder while the PDF downloads and parses. noData shows when no file prop is provided.

    TypeScript version

    react-pdf ships its own type definitions. Here’s a typed version of the viewer with all the features from the previous sections combined:

    import { useState } from "react";
    import {
    Document,
    Page,
    Thumbnail,
    Outline,
    pdfjs,
    } from "react-pdf";
    import type { DocumentProps } from "react-pdf";
    import "react-pdf/dist/Page/TextLayer.css";
    import "react-pdf/dist/Page/AnnotationLayer.css";
    pdfjs.GlobalWorkerOptions.workerSrc = new URL(
    "pdfjs-dist/build/pdf.worker.min.mjs",
    import.meta.url,
    ).toString();
    interface PDFViewerProps {
    file: DocumentProps["file"];
    initialPage?: number;
    initialScale?: number;
    }
    const PDFViewer = ({
    file,
    initialPage = 1,
    initialScale = 1.0,
    }: PDFViewerProps) => {
    const [numPages, setNumPages] = useState<number>(0);
    const [pageNumber, setPageNumber] = useState<number>(initialPage);
    const [scale, setScale] = useState<number>(initialScale);
    const [loadError, setLoadError] = useState<Error | null>(null);
    const [retryKey, setRetryKey] = useState<number>(0);
    const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
    const goToNextPage = () =>
    setPageNumber((prev) => (numPages ? Math.min(prev + 1, numPages) : prev));
    const zoomIn = () => setScale((prev) => Math.min(prev + 0.25, 3));
    const zoomOut = () => setScale((prev) => Math.max(prev - 0.25, 0.5));
    if (loadError) {
    return (
    <div>
    <p>Failed to load: {loadError.message}</p>
    <button
    onClick={() => {
    setLoadError(null);
    setRetryKey((k) => k + 1);
    }}
    >
    Retry
    </button>
    </div>
    );
    }
    return (
    <Document
    key={retryKey}
    file={file}
    onLoadSuccess={({ numPages }) => setNumPages(numPages)}
    onLoadError={setLoadError}
    loading={<div>Loading PDF…</div>}
    >
    <div style={{ display: "flex", gap: "16px" }}>
    {/* Thumbnail sidebar */}
    <aside style={{ width: "150px", overflowY: "auto" }}>
    {Array.from({ length: numPages }, (_, i) => (
    <div
    key={i}
    onClick={() => setPageNumber(i + 1)}
    style={{
    cursor: "pointer",
    border:
    pageNumber === i + 1
    ? "2px solid #0078d4"
    : "2px solid transparent",
    marginBottom: "4px",
    }}
    >
    <Thumbnail pageNumber={i + 1} width={130} />
    </div>
    ))}
    </aside>
    {/* Main content */}
    <div>
    <nav style={{ display: "flex", gap: "8px", alignItems: "center" }}>
    <button onClick={goToPrevPage} disabled={pageNumber <= 1}>
    Prev
    </button>
    <span>
    Page {pageNumber} of {numPages}
    </span>
    <button onClick={goToNextPage} disabled={!numPages || pageNumber >= numPages}>
    Next
    </button>
    <span>|</span>
    <button onClick={zoomOut} disabled={scale <= 0.5}>
    </button>
    <span>{Math.round(scale * 100)}%</span>
    <button onClick={zoomIn} disabled={scale >= 3}>
    +
    </button>
    </nav>
    <Outline
    onItemClick={({ pageNumber: pg }) => setPageNumber(pg)}
    />
    <Page pageNumber={pageNumber} scale={scale} />
    </div>
    </div>
    </Document>
    );
    };
    export default PDFViewer;

    Key typing details:

    • DocumentProps["file"] gives you the exact union type that Document accepts for its file prop (URL string, ArrayBuffer, Uint8Array, or a configuration object).
    • onLoadSuccess receives a PDFDocumentProxy object, but destructuring { numPages } is enough for most use cases.
    • react-pdf also exports PageProps, ThumbnailProps, and OutlineProps if you need to type-check wrapper components.

    Limitations of react-pdf

    react-pdf is a rendering library, not a full PDF editor. Here are its constraints:

    • No annotations — You can’t add highlights, sticky notes, or drawings.
    • No form filling — Interactive PDF forms (AcroForms) aren’t supported.
    • No digital signatures — No support for signing or verifying signatures.
    • No editing — Text editing, page reordering, and merging aren’t available.
    • No multi-format support — Only PDF files are supported (no DOCX, XLSX, or images).
    • Limited text selection — The text layer works but can be inconsistent with complex layouts.
    • Large file performance — Rendering is JavaScript-based, which can slow down on PDFs with hundreds of pages.

    If your app only needs to display PDFs, these limitations may not matter. If you need annotations, forms, or signatures, read on for an alternative.

    Building a PDF viewer with Nutrient Web SDK

    This section shows how to build the same viewer using Nutrient Web SDK. You’ll set up a React project, install the SDK, and render a PDF with a full-featured UI out of the box.

    Create a React project

    Use Vite to scaffold a new React application:

    Terminal window
    npm create vite@latest nutrient-react-example -- --template react
    cd nutrient-react-example

    Install and configure Nutrient

    Install the @nutrient-sdk/viewer package:

    Terminal window
    npm i @nutrient-sdk/viewer

    Nutrient Web SDK loads its WebAssembly and supporting files from a local path, so copy them to the public folder. Install the copy plugin:

    Terminal window
    npm install -D rollup-plugin-copy

    Update vite.config.ts to copy the SDK assets during build:

    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import copy from "rollup-plugin-copy";
    export default defineConfig({
    plugins: [
    copy({
    targets: [
    {
    src: "node_modules/@nutrient-sdk/viewer/dist/nutrient-viewer-lib",
    dest: "public/",
    },
    ],
    hook: "buildStart",
    }),
    react(),
    ],
    });

    Render a PDF

    Replace src/App.tsx with:

    import { useEffect, useRef } from "react";
    function App() {
    const containerRef = useRef<HTMLDivElement>(null);
    useEffect(() => {
    const container = containerRef.current;
    let cleanup = () => {};
    (async () => {
    const NutrientViewer = (await import("@nutrient-sdk/viewer")).default;
    // Unload any previous instance.
    NutrientViewer.unload(container);
    if (container && NutrientViewer) {
    NutrientViewer.load({
    container,
    document: "/example.pdf",
    baseUrl: `${window.location.protocol}//${
    window.location.host
    }/${import.meta.env.BASE_URL ?? ""}`,
    });
    }
    cleanup = () => {
    NutrientViewer.unload(container);
    };
    })();
    return cleanup;
    }, []);
    return <div ref={containerRef} style={{ height: "100vh", width: "100vw" }} />;
    }
    export default App;

    Start the app:

    Terminal window
    npm run dev

    Because Nutrient is a commercial product, you’ll see an evaluation watermark. To remove it, contact Sales.

    You can find the finished code on GitHub(opens in a new tab).

    What you get out of the box

    With no additional code, the Nutrient viewer includes:

    • Toolbar with zoom, page navigation, search, and layout controls
    • 15+ annotation types (highlights, stamps, shapes, freehand, comments)
    • PDF form filling and submission
    • Electronic and digital signatures
    • Text editing and page management
    • Multi-format rendering (PDF, DOCX, XLSX, and images)
    • WebAssembly-based rendering optimized for large documents
    • Screen reader-compatible text layers and keyboard navigation

    react-pdf vs. Nutrient: Feature comparison

    Featurereact-pdfNutrient Web SDK
    LicenseMITCommercial
    Bundle size~300 KB + PDF.js worker~5 MB (includes WASM engine)
    Prebuilt UINone (build your own)Full toolbar, sidebar, modals
    AnnotationsNot available15+ types with programmatic API
    Form fillingNot supportedAcroForms and XFA
    Digital signaturesNot supportedSign and verify
    Text editingNot supportedIn-document editing
    File format supportPDF onlyPDF, DOCX, XLSX, and images
    Rendering engineJavaScript (PDF.js)WebAssembly
    Text selectionBasic text layerAdvanced selection with copy/paste
    Mobile supportManual responsive CSSBuilt-in touch gestures and responsive UI
    AccessibilityManual ARIA workWCAG-aligned out of the box
    SupportCommunity (GitHub issues)Commercial support with SLA

    When to choose react-pdf

    react-pdf is a good choice when:

    • You need read-only PDF viewing — Display documents without editing or annotation features.
    • You want a lightweight dependency — The MIT-licensed library adds minimal bundle overhead.
    • You need full UI control — You’re building a custom viewer design and want to own every pixel.
    • You’re prototyping — Quick setup for proof-of-concept work before committing to a paid solution.

    When to choose Nutrient

    Nutrient fits better when:

    • You need annotations, forms, or signatures — These features would take months to build from scratch.
    • You’re rendering large or complex PDFs — WebAssembly rendering handles heavy documents more efficiently.
    • You need multi-format support — Displaying DOCX, XLSX, or image files alongside PDFs.
    • Accessibility compliance matters — Built-in screen reader support, keyboard navigation, and WCAG alignment.
    • You want to ship faster — Prebuilt UI components reduce the time from prototype to production.

    Accessibility

    • react-pdf — The text layer enables basic screen reader access, but you’ll need to add ARIA attributes, keyboard navigation, and focus management yourself.
    • Nutrient Web SDK — Ships with screen reader-compatible text layers, full keyboard access (tab order, shortcuts, focus trapping), high-contrast modes, and accessible annotation tools. Aligned with WCAG and Section 508.

    Accessibility is hard to retrofit. If your project has compliance requirements, evaluate this early.

    Conclusion

    This tutorial covered building a React PDF viewer with react-pdf — from basic rendering through zoom, thumbnails, outline navigation, error handling, and TypeScript. For read-only viewing with a custom UI, react-pdf works well.

    If you need annotations, forms, signatures, or multi-format support, try Nutrient’s React PDF library. It ships with a complete UI and can be integrated in minutes.

    FAQ

    How do I build a React PDF viewer with react-pdf?

    Install react-pdf (npm install react-pdf), import the Document and Page components, configure the PDF.js worker in the same file, and build your own navigation controls. This tutorial walks through the complete process, including zoom, thumbnails, and outline navigation.

    What are the main limitations of react-pdf?

    react-pdf is a rendering library. It doesn’t support annotations, form filling, digital signatures, text editing, or multi-format files (DOCX, XLSX). The text layer can be inconsistent with complex layouts, and JavaScript-based rendering can slow down on large documents. See the full limitations list.

    Does react-pdf support TypeScript?

    Yes. react-pdf ships type definitions and exports types like DocumentProps, PageProps, ThumbnailProps, and OutlineProps. This tutorial includes a full TypeScript implementation with a typed PDFViewerProps interface.

    Hulya Masharipov

    Hulya Masharipov

    Technical Writer

    Hulya is a frontend web developer and technical writer who enjoys creating responsive, scalable, and maintainable web experiences. She’s passionate about open source, web accessibility, cybersecurity privacy, and blockchain.

    Explore related topics

    Try for free Ready to get started?