Recipes

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/:

ComponentUse for
PdfPreviewDialogModal preview — most common case
PdfViewerEmbedded 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

PropTypeDescription
urlstringPDF URL (presigned or public)
filenamestringShown in dialog title and download tooltip
openbooleanDialog open state
onOpenChange(open: boolean) => voidCalled on open/close

PdfViewer

PropTypeDefaultDescription
urlstringrequiredPDF URL
filenamestringShown in download button tooltip
classNamestringRoot container class — set height here
showToolbarbooleantrueHide to embed without chrome
showDownloadbooleantrueHide 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:

  1. Zero bundle impact. No pdfjs-dist in your JS bundle.
  2. 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)}
/>

On this page