Add PDF Preview
Preview PDF files in a modal or embed a PDF viewer on a page
Add PDF Preview
TL;DR: Drop
<PdfPreviewDialog />next to any file row. Pass it a presigned URL — it handles rendering, zoom, paging, and download.
When You Need This
- Previewing invoices, contracts, or attachments without leaving the page
- Embedding a PDF viewer inside a detail page section
- Downloading a PDF via a clear button instead of a bare link
What You Get
Two components in src/components/pdf-viewer/:
| Component | Use for |
|---|---|
PdfPreviewDialog | Modal preview — most common case |
PdfViewer | Embedded viewer — standalone in a page or section |
Both include continuous scroll, zoom (0.5x – 3x, fit-to-width at 100%), live page indicator, and a download button.
Preview Dialog (Most Common)
Open a PDF from a row action, a thumbnail click, or an attachment list. You pass a presigned URL and the filename — the dialog does the rest.
"use client"
import { useState } from "react"
import { PdfPreviewDialog } from "@/components/pdf-viewer"
export function InvoiceRowActions({ invoice }) {
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null)
const handlePreview = async () => {
const { url } = await fetchPresignedUrl(invoice.fileId)
setPreview({ url, filename: invoice.filename })
}
return (
<>
<Button onClick={handlePreview}>Preview</Button>
<PdfPreviewDialog
url={preview?.url ?? ""}
filename={preview?.filename}
open={!!preview}
onOpenChange={(open) => {
if (!open) setPreview(null)
}}
/>
</>
)
}The dialog is 1100×600 (responsive on smaller screens) — matches the standard layout used by accounting apps. At 100% zoom the PDF fills the width and you scroll vertically.
Getting a Presigned URL
For S3 / Supabase-backed files:
import { getStorage } from "@/lib/storage"
const url = await getStorage().generatePresignedDownloadUrl(fileKey, 3600)See Storage Setup for full setup.
Embedded Viewer
For detail pages that show a single PDF inline (e.g., a "Document" tab on a contract):
import { PdfViewer } from "@/components/pdf-viewer"
<PdfViewer
url={presignedUrl}
filename="contract-2026.pdf"
className="h-[600px]"
/>Always set a height on the wrapper (h-[600px], h-full, etc.) — PdfViewer fills its container. Without a bounded height it'll collapse to zero.
Props Reference
PdfPreviewDialog
| Prop | Type | Description |
|---|---|---|
url | string | PDF URL (presigned or public) |
filename | string | Shown in dialog title and download tooltip |
open | boolean | Dialog open state |
onOpenChange | (open: boolean) => void | Called on open/close |
PdfViewer
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | required | PDF URL |
filename | string | — | Shown in download button tooltip |
className | string | — | Root container class — set height here |
showToolbar | boolean | true | Hide to embed without chrome |
showDownload | boolean | true | Hide to disable download |
How Rendering Works
pdfjs-dist is loaded from a CDN at runtime (via dynamic import() with /* webpackIgnore: true */) — it's never bundled by webpack. Two consequences:
- Zero bundle impact. No
pdfjs-distin your JS bundle. - First-render latency. The first open fetches ~1 MB from
unpkg.com. Cached after that.
The version is pinned via a PDFJS_VERSION constant in pdf-viewer.tsx — update it there when bumping.
This architecture sidesteps a webpack ESM bug that breaks pdfjs-dist in Next.js dev mode. Don't try to npm install pdfjs-dist — it won't help and may re-introduce the bug.
Common Mistakes
Don't pass a raw S3 key as url:
// ❌ Wrong — the browser can't fetch a bucket key
<PdfPreviewDialog url={file.s3Key} ... />
// ✅ Correct — generate a presigned URL first
const url = await getStorage().generatePresignedDownloadUrl(file.s3Key)
<PdfPreviewDialog url={url} ... />Don't forget to bound the height of PdfViewer:
// ❌ Wrong — collapses to zero height
<PdfViewer url={url} />
// ✅ Correct
<PdfViewer url={url} className="h-[600px]" />Don't render the dialog conditionally — render it always, control with open:
// ❌ Wrong — unmounts and reloads PDF every time
{preview && <PdfPreviewDialog url={preview.url} open={true} ... />}
// ✅ Correct — stays mounted, just opens/closes
<PdfPreviewDialog
url={preview?.url ?? ""}
open={!!preview}
onOpenChange={(open) => !open && setPreview(null)}
/>Related
- Add File Upload — uploading files into storage
- Storage Setup — configuring S3, R2, Supabase, or MinIO