How to build a React PDF viewer with react-pdf (2026)
Table of contents
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.
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:
- Node.js version 18 or later(opens in a new tab)
- A package manager — npm(opens in a new tab) (included with Node.js) or Yarn(opens in a new tab)
- Vite(opens in a new tab) for project scaffolding (the tutorial uses it below)
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:
npm create vite@latest react-pdf-demo -- --template reactChange into the project directory and install dependencies:
cd react-pdf-demonpm installInstall react-pdf and configure the worker
Install the react-pdf package:
npm install react-pdfPlace 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:
npm run devYou 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 thatDocumentaccepts for itsfileprop (URL string,ArrayBuffer,Uint8Array, or a configuration object).onLoadSuccessreceives aPDFDocumentProxyobject, but destructuring{ numPages }is enough for most use cases.- react-pdf also exports
PageProps,ThumbnailProps, andOutlinePropsif 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:
npm create vite@latest nutrient-react-example -- --template reactcd nutrient-react-exampleInstall and configure Nutrient
Install the @nutrient-sdk/viewer package:
npm i @nutrient-sdk/viewerpnpm add @nutrient-sdk/vieweryarn add @nutrient-sdk/viewerNutrient Web SDK loads its WebAssembly and supporting files from a local path, so copy them to the public folder. Install the copy plugin:
npm install -D rollup-plugin-copyUpdate 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:
npm run devBecause 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
| Feature | react-pdf | Nutrient Web SDK |
|---|---|---|
| License | MIT | Commercial |
| Bundle size | ~300 KB + PDF.js worker | ~5 MB (includes WASM engine) |
| Prebuilt UI | None (build your own) | Full toolbar, sidebar, modals |
| Annotations | Not available | 15+ types with programmatic API |
| Form filling | Not supported | AcroForms and XFA |
| Digital signatures | Not supported | Sign and verify |
| Text editing | Not supported | In-document editing |
| File format support | PDF only | PDF, DOCX, XLSX, and images |
| Rendering engine | JavaScript (PDF.js) | WebAssembly |
| Text selection | Basic text layer | Advanced selection with copy/paste |
| Mobile support | Manual responsive CSS | Built-in touch gestures and responsive UI |
| Accessibility | Manual ARIA work | WCAG-aligned out of the box |
| Support | Community (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
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.
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.
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.