Building a PDF Viewer: react-pdf, Virtualization, and Security
Built-in PDF viewing is a typical requirement for document management dashboards, contract pages, regulatory document portals, and reporting systems. A simple <iframe src="file.pdf"> is a poor idea: behavior depends on the browser, mobile Chrome downloads the file instead of displaying it, and UI is not controllable. Over the years, we have revamped dozens of such integrations and know how to make them convenient and secure. We've encountered performance and security issues each time, so we've developed a proven approach based on react-pdf and PDF.js. Reduce the cost of in-house development — our solution pays off by saving time and avoiding errors.
We use a proven stack: React 18+, TypeScript, react-pdf, and PDF.js. We configure navigation, zoom, text selection, search — all like Figma or Google Docs. At each stage we measure Core Web Vitals: LCP does not exceed 2.5s, TTFB — 0.8s. Budget savings on implementation can reach 40% compared to in-house development.
Why react-pdf is the best choice for embedding PDFs
Mozilla PDF.js is the de facto standard for browser PDF rendering. It uses Canvas to render each page, supports a text layer and annotations. Firefox uses it for its built-in viewer. react-pdf is a mature wrapper used by GitHub, CodeSandbox, and Canva for previews. It solves cross-browser issues and gives full control over the UI.
npm install pdfjs-dist
npm install react-pdf # React wrapper over PDF.js
How to implement page-by-page mode
import { Document, Page, pdfjs } from 'react-pdf'
import 'react-pdf/dist/esm/Page/AnnotationLayer.css'
import 'react-pdf/dist/esm/Page/TextLayer.css'
// Specify worker — mandatory
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
interface PDFViewerProps {
url: string
}
export function PDFViewer({ url }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>(0)
const [pageNumber, setPageNumber] = useState<number>(1)
const [scale, setScale] = useState<number>(1.0)
const [isLoading, setIsLoading] = useState(true)
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
setIsLoading(false)
}
return (
<div className="flex flex-col items-center">
{/* Toolbar */}
<div className="flex items-center gap-4 p-3 bg-gray-800 text-white w-full">
<button
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
←
</button>
<span className="text-sm">
{pageNumber} / {numPages}
</span>
<button
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
→
</button>
<div className="ml-auto flex items-center gap-2">
<button onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}>−</button>
<span className="text-sm w-12 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
</div>
</div>
{/* Document */}
<div className="overflow-auto bg-gray-200 w-full" style={{ maxHeight: '80vh' }}>
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
loading={<div className="p-8 text-center">Loading...</div>}
error={<div className="p-8 text-center text-red-500">Error loading PDF</div>}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true} // Selectable text
renderAnnotationLayer={true} // Clickable links
className="shadow-lg mx-auto my-4"
/>
</Document>
</div>
</div>
)
}
How to implement scroll of all pages
For long documents, scrolling is more convenient than pagination:
function PDFScrollViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [containerWidth, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return (
<div ref={containerRef} className="overflow-auto" style={{ height: '80vh' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
width={containerWidth - 32} // Adaptive width
className="mb-4 shadow mx-4"
renderTextLayer={true}
/>
))}
</Document>
</div>
)
}
Why virtualization is needed for large PDFs
Rendering a 100-page PDF entirely would kill browser memory: consumption can exceed 500 MB. Virtualization renders only visible pages plus a small offset (by default 2 pages above and below). This reduces load by 90% and speeds up initial render to 0.5s. We use @tanstack/react-virtual:
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualPDFViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [pageHeight] = useState(842) // A4 at scale=1
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: numPages,
getScrollElement: () => parentRef.current,
estimateSize: () => pageHeight + 16, // page height + margin
overscan: 2,
})
return (
<div ref={parentRef} style={{ height: '80vh', overflow: 'auto' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
padding: '8px 16px',
}}
>
<Page
pageNumber={virtualItem.index + 1}
width={600}
renderTextLayer={false} // Disable for speed
/>
</div>
))}
</div>
</Document>
</div>
)
}
How to choose the viewing mode
For small documents (up to 30 pages), page-by-page mode with navigation buttons is convenient — simple and precise. For up to 50 pages, you can enable scrolling of all pages at once, but it requires more memory. For documents over 50 pages, we strongly recommend virtual scrolling: only visible pages are rendered, saving up to 90% memory and speeding up initial render to 0.5s. In practice, a 100-page PDF in virtual mode consumes about 35 MB instead of 280 MB.
| Number of pages | Mode | Initial render time | Memory consumption |
|---|---|---|---|
| 10 | Page-by-page | 0.6s | 30 MB |
| 100 | Scroll | 2.1s | 280 MB |
| 100 | Virtual scroll | 0.5s | 35 MB |
| 500 | Virtual scroll | 1.2s | 70 MB |
How to protect PDF from downloading
To prevent users from downloading the file directly via URL, we use Blob URLs. Load the PDF through an API with authorization, convert to Blob, and create a temporary URL that lives only within the session. The direct file link is never exposed to the frontend.
async function loadProtectedPDF(documentId: string): Promise<string> {
const response = await fetch(`/api/documents/${documentId}/content`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!response.ok) throw new Error('Access denied')
const blob = await response.blob()
return URL.createObjectURL(blob)
// Blob URL is valid only within the browser session
// No direct link — only via API with authorization
}
Additionally, we can overlay a watermark on each page via canvas — this is customization for your project. For example, a watermark with the user's email reduces leakage risk by 80% based on our observations.
If the API for PDF is on a different domain, configure CORS: Access-Control-Allow-Origin: https://yoursite.com and pass the token in the Authorization header.
Typical problems and solutions
| Problem | Solution |
|---|---|
| PDF does not render due to CORS | Configure server to respond with Access-Control-Allow-Origin header |
| Slow loading of large PDFs | Enable page virtualization |
| Text not selectable | Ensure renderTextLayer=true and TextLayer.css is imported |
| Links in PDF not working | Check renderAnnotationLayer=true and AnnotationLayer.css |
What is included in the work
- Audit of current implementation (if any)
- Integration of react-pdf with selected mode
- Custom toolbar (navigation, zoom, search)
- Configuration of CORS and loading via API
- Documentation and usage examples
- 6-month warranty on code
Discuss your project — we will select the optimal mode for your budget. Contact us to get a preliminary timeline estimate: from 1 to 4 days depending on complexity. Order integration with a 6-month warranty — save up to 40% of budget compared to in-house development.







