react-pdf tutorial: Build a PDF viewer in React (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.
Nutrient Web SDK renders, annotates, and fills forms client side. Try the live demo — no signup required.
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 built in.
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. It’s maintained by Wojciech Maj(opens in a new tab).
This tutorial uses react-pdf 10.5.0 and its versioned documentation(opens in a new tab). It displays existing PDFs; it’s a different package from @react-pdf/renderer, which creates PDFs. For direct worker and canvas control in Next.js, follow the PDF.js React tutorial.
react-pdf provides rendering components rather than a complete viewer toolbar. Its annotation layer displays supported existing annotations, and <Page renderForms /> enables interactive forms. Annotation authoring, document editing, and signing need additional tooling.
Prerequisites
Before starting, make sure you have:
- Node.js 22.13 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 covers project setup, PDF rendering, and common viewer features. The complete App examples are standalone replacements for src/App.jsx, each demonstrating one feature. Replacing the file with the next example won’t retain earlier controls. For navigation, zoom, thumbnails, and an outline in one component, use the combined TypeScript example.
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-pdf@10.5.0Place 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. Configure the worker 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;This single file includes the worker configuration, text layer CSS, and annotation layer CSS. 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 devFor another implementation, see 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 aria-label="Page number" type="number" min={1} max={numPages || 1} value={pageNumber} onChange={(e) => { const page = Number(e.target.value); if (Number.isInteger(page) && 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. Replace src/App.jsx with this standalone example to try 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. Replace src/App.jsx with this standalone sidebar example; it doesn’t include the previous zoom controls:
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} style={{ cursor: "pointer", border: pageNumber === index + 1 ? "2px solid #0078d4" : "2px solid transparent", marginBottom: "8px", }} > <Thumbnail pageNumber={index + 1} width={130} onItemClick={({ pageNumber }) => setPageNumber(pageNumber)} /> </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. Replace src/App.jsx with this standalone outline example. Its onItemClick callback receives { pageNumber } for navigation; thumbnails and zoom controls aren’t included:
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); setPageNumber((page) => Math.min(page, 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. For this version, scaffold a separate project with npm create vite@latest react-pdf-ts -- --template react-ts, install react-pdf@10.5.0, and save the component below as src/PDFViewer.tsx. Put document.pdf in public. Then render <PDFViewer file="/document.pdf" /> from src/App.tsx after importing it. When changing documents, give the component a key based on the document URL so its page and error state reset.
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>(Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 1); const [scale, setScale] = useState<number>(Number.isFinite(initialScale) ? Math.min(3, Math.max(0.5, initialScale)) : 1); 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); setPageNumber((page) => Math.min(page, 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} style={{ cursor: "pointer", border: pageNumber === i + 1 ? "2px solid #0078d4" : "2px solid transparent", marginBottom: "4px", }} > <Thumbnail pageNumber={i + 1} width={130} onItemClick={({ pageNumber }) => setPageNumber(pageNumber)} /> </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;The following typing details apply:
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 annotation authoring UI — The annotation layer displays supported existing annotations, but there’s no built-in toolbar for creating highlights or drawings.
- Forms need configuration — Set
renderFormsonPageto enable supported interactive forms. Saving changed values requires additional integration. - No digital signatures — No support for signing or verifying signatures.
- No editing — Text editing, page reordering, and merging aren’t available.
- No multiformat 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 document performance — The thumbnail examples render every page. For large documents, render thumbnails only when they enter the visible area and test memory use.
If your app only needs to display PDFs, these limitations may not matter. If you need a built-in annotation toolbar, document editing, or signing, 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 from the first render.
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
This example uses Nutrient Web SDK 1.22.0. Install it with npm install @nutrient-sdk/viewer@1.22.0.
Download the matching runtime assets archive(opens in a new tab) and extract its contents into public, preserving the nutrient-viewer-lib directory. On macOS or Linux, run these commands from the project root:
curl --fail --location https://cdn.cloud.nutrient.io/assets/pspdfkit-web-assets@1.22.0.zip -o nutrient-assets.zipunzip nutrient-assets.zip -d publicOn Windows, download the same ZIP and extract it into public using your archive tool. This example serves runtime assets from the app’s origin. See the asset hosting guide for deployment options. The paths below assume the app is hosted at the domain root.
Render a PDF
Place a PDF at public/example.pdf. Then replace src/App.jsx with the following. The effect imports the browser SDK after mounting, reports load failures, and unloads its own container during cleanup:
import { useEffect, useRef, useState } from "react";
export default function App() { const hostRef = useRef(null); const [error, setError] = useState("");
useEffect(() => { const host = hostRef.current; if (!host) return;
// Each effect owns a container, including during Strict Mode cleanup. const container = document.createElement("div"); container.style.height = "100%"; host.appendChild(container); let cancelled = false; let NutrientViewer;
async function loadViewer() { try { NutrientViewer = (await import("@nutrient-sdk/viewer")).default; if (cancelled) return; await NutrientViewer.load({ container, document: "/example.pdf", baseUrl: `${window.location.origin}/`, }); if (cancelled) NutrientViewer.unload(container); } catch (cause) { if (!cancelled) setError(String(cause)); } } void loadViewer();
return () => { cancelled = true; NutrientViewer?.unload(container); container.remove(); }; }, []);
return ( <> {error && <p role="alert">Failed to load PDF: {error}</p>} <div ref={hostRef} style={{ height: "100vh", width: "100%" }} /> </> );}Start the app:
npm run devBecause Nutrient is a commercial product, you’ll see an evaluation watermark. To remove it, contact Sales.
For additional SDK examples, see GitHub(opens in a new tab).
Available viewer features
Nutrient provides the following capabilities. Availability depends on your licensed components and deployment; collaboration requires additional setup:
- Toolbar with zoom, page navigation, search, and layout controls
- 17+ annotation types (highlights, stamps, shapes, freehand, and comments)
- PDF form filling and submission
- Electronic and digital signatures
- Text editing and page management
- Multiformat 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 |
| Prebuilt UI | None (build your own) | Full toolbar, sidebar, modals |
| Annotations | Display existing annotations; no authoring toolbar | Annotation tools and programmatic API |
| Form filling | Interactive forms with renderForms | Form filling and creation |
| 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 | Accessibility features; validate your integration |
| Support | Community (GitHub issues) | Commercial support; terms depend on plan |
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 — A prebuilt UI can reduce custom implementation work.
- You’re rendering large or complex PDFs — Benchmark representative documents against your performance requirements.
- You need multiformat support — Displaying DOCX, XLSX, or image files alongside PDFs.
- Accessibility compliance matters — Built-in screen reader support, keyboard navigation, and Web Content Accessibility Guidelines (WCAG) alignment.
- You want to ship faster — Prebuilt UI components reduce the time from prototype to production.
Does a React PDF viewer need Document Engine?
The Nutrient example in this tutorial runs in the browser without Document Engine. Choose the deployment based on the work your application needs to do:
- Browser viewing and editing — Use Nutrient Web SDK for the viewer UI and the features included in your license. Start with the React and Vite setup guide.
- A viewer with server processing or collaboration — Combine Web SDK with Document Engine. Check the deployment comparison for the required components and infrastructure.
- Document processing without a viewer — Consider Nutrient DWS Processor API for hosted conversion, OCR, or other document operations. An API response doesn’t provide a React viewer UI.
For an existing react-pdf app, follow the migration guide before evaluating additional components in a Nutrient trial.
Accessibility
- react-pdf — The text layer enables basic screen reader access. Adding Accessible Rich Internet Applications (ARIA) attributes, keyboard navigation, and focus management is up to you.
- 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. Validate your application against its accessibility requirements.
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 multiformat support, try Nutrient’s React PDF library. It provides a viewer UI with optional capabilities based on your license.
Related React PDF guides
This tutorial covers the react-pdf library specifically. For other approaches to viewing documents in React, see:
- How to build a React PDF viewer — A general React PDF viewer built with Nutrient Web SDK.
- Render PDFs in React with PDF.js — A lower-level approach using Mozilla’s PDF.js (
pdfjs-dist) directly. - How to build a React file viewer — View PDFs alongside Office and image formats in one component.
- How to build a Next.js PDF viewer — The same viewer in a Next.js app, including server-side rendering (SSR) considerations.
FAQ
See our step-by-step migration guide for side-by-side code comparisons covering viewer setup, text search, annotations, forms, thumbnails, and more.
Install react-pdf (npm install react-pdf@10.5.0), 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 supplies viewing components without an annotation authoring toolbar, digital signing, or document editing. It displays supported existing annotations and can render interactive forms with renderForms. Large documents need careful thumbnail and page rendering. 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.