React

free-pdf-sdk/react exports FreePdfViewer, a component for React 17, 18 and 19. Props configure the viewer, on* props receive its events, and a ref gives you the full API.

tsx
import { FreePdfViewer } from "free-pdf-sdk/react";

export function ContractViewer() {
  return (
    <FreePdfViewer
      baseUrl="/free-pdf-sdk/"
      document={{ url: "/files/contract.pdf" }}
      style={{ height: "80vh" }}
    />
  );
}

Install

bash
npm install free-pdf-sdk
npx free-pdf-sdk copy-viewer public/free-pdf-sdk

The second command copies the viewer files (viewer/, version.json, the licences) into public/free-pdf-sdk, which Vite, Create React App and Next.js serve at /free-pdf-sdk/. That URL is baseUrl, and it must be on the same origin as your app. Run the command again after every upgrade, for example from a script:

json
{
  "scripts": {
    "postinstall": "free-pdf-sdk copy-viewer public/free-pdf-sdk"
  }
}

Props

The component takes every FreePdf.init option as a prop, plus event handlers and the styling of its container.

Prop Type Notes
baseUrl string Required. URL of the viewer folder, on the page's origin.
document { url, name?, fetchInit? } or { data, name? } The PDF to open. data is an ArrayBuffer, Uint8Array or Blob.
mode Mode view (default, read-only), review, annotate, fill, sign, organize, edit or full.
features FeatureFlags Feature switches on top of the mode, such as { stamps: false }.
ui UiOptions Interface options, such as { toolsPanel: false }.
disabledElements ElementId[] Parts of the interface to hide, such as ["download", "print"].
theme "light" | "dark" | "system" Default system.
author string Author of new comments.
locale string Interface language, such as en, de or zh-Hans.
password string Used when document is opened: opens a protected file and lifts owner restrictions.
timeoutMs number How long requests wait for a reply (default 60000). Read once, at mount.
className string Class of the container div the viewer fills.
style CSSProperties Style of that div. Give it a height.

The container has position: relative and a min-height of 200px. Without a height it stays 200px tall: set one through style or className (a fixed height, 100% of a sized parent, flex: 1 in a flex column).

Events

Each viewer event has a handler prop:

Prop Receives When
onReady { sdk, pdfcraftRev } The viewer is configured and ready for requests.
onDocumentLoaded { pageCount, permissions } A document opened.
onDocumentError { code, message } A document could not be opened.
onAnnotationsChanged { count } Comments were added, changed or removed, by the user or the API.
onDirtyChanged { dirty } The document gained or lost unsaved changes.
onError { code, message } Something failed outside a request: the viewer could not start, a prop change was refused.
onSaveRequested { name, saveAs, data } The user pressed Save in the viewer. data is the PDF as a Uint8Array; the viewer now treats it as saved.

The component always calls the handler from the latest render, so inline arrow functions are fine and you don't need useCallback.

tsx
const [count, setCount] = useState(0);
const [dirty, setDirty] = useState(false);

<FreePdfViewer
  baseUrl="/free-pdf-sdk/"
  mode="annotate"
  document={{ url: "/files/contract.pdf" }}
  onAnnotationsChanged={({ count }) => setCount(count)}
  onDirtyChanged={({ dirty }) => setDirty(dirty)}
  onError={({ code, message }) => console.error(code, message)}
  style={{ height: "80vh" }}
/>;

Call the API with a ref

A ref to the component is a FreePdfViewerHandle with two methods:

  • viewer() returns the viewer's API once it is ready, else null.
  • whenReady() resolves with the API once the viewer is ready and its first document has loaded (or failed to).
tsx
import { useRef } from "react";
import { FreePdfViewer, type FreePdfViewerHandle } from "free-pdf-sdk/react";

export function Viewer() {
  const ref = useRef<FreePdfViewerHandle>(null);

  async function goToLastPage() {
    const viewer = await ref.current?.whenReady();
    if (!viewer) return;
    await viewer.goToPage(await viewer.getPageCount());
  }

  return (
    <>
      <button onClick={goToLastPage}>Last page</button>
      <FreePdfViewer ref={ref} baseUrl="/free-pdf-sdk/" document={{ url: "/files/report.pdf" }} style={{ height: "80vh" }} />
    </>
  );
}

Every API method is async and rejects with a FreePdfError that has a code and a message. Import the class and the types from the main entry point:

ts
import { FreePdfError, type FreePdfViewer as Api } from "free-pdf-sdk";

Change props

Changing a prop calls the matching API method on the running viewer, in this order: setMode, setFeatures, setUi, setDisabledElements, setTheme, setAuthor, setLocale, then load for a new document. A refused change is reported through onError.

tsx
import type { Mode } from "free-pdf-sdk";

const [mode, setMode] = useState<Mode>("view");
const [dark, setDark] = useState(false);

<FreePdfViewer
  baseUrl="/free-pdf-sdk/"
  mode={mode}
  theme={dark ? "dark" : "light"}
  features={{ stamps: false }}
  document={{ url: `/files/${fileId}.pdf` }}
  style={{ height: "80vh" }}
/>;

How changes are detected:

  • features, ui and disabledElements are compared by value, so object literals written inline don't cause calls on every render.
  • document with a url is compared by url and name. With data, it is compared by the identity of the bytes: keep the same ArrayBuffer, Uint8Array or Blob (in state, or with useMemo) or the document reloads on every render.
  • features replaces the overrides: removing the prop goes back to the mode's own features. ui options you leave out keep their value, and removing mode, theme, author or locale keeps the current value.
  • password is only used when a document is opened; change it together with document.
  • Changing baseUrl destroys the viewer and mounts a new one. timeoutMs is read at mount.

Calls you make through the ref's API don't change the props. If you call viewer.setMode() yourself, keep your mode prop in step, or the next prop change won't do what you expect.

Example: annotate a contract and save the comments

This component opens a PDF in annotate mode, restores comments saved earlier as XFDF, and saves them back to your server. It also handles the viewer's own Save button.

tsx
import { useRef, useState } from "react";
import { FreePdfError } from "free-pdf-sdk";
import { FreePdfViewer, type FreePdfViewerHandle } from "free-pdf-sdk/react";

type Props = { contractId: string; userName: string };

export function ContractReview({ contractId, userName }: Props) {
  const ref = useRef<FreePdfViewerHandle>(null);
  const [count, setCount] = useState(0);
  const [status, setStatus] = useState("");

  // Runs for every document that opens: restore the comments saved for it.
  async function restoreComments() {
    const viewer = await ref.current?.whenReady();
    if (!viewer) return;
    const response = await fetch(`/api/contracts/${contractId}/annotations`);
    if (response.status === 404) return; // nothing saved yet
    const imported = await viewer.importXfdf(await response.text(), { replace: true });
    setStatus(`Restored ${imported} comments`);
  }

  async function saveComments() {
    const viewer = ref.current?.viewer();
    if (!viewer) return;
    try {
      const xfdf = await viewer.exportXfdf();
      await fetch(`/api/contracts/${contractId}/annotations`, {
        method: "PUT",
        headers: { "Content-Type": "application/vnd.adobe.xfdf" },
        body: xfdf,
      });
      setStatus("Comments saved");
    } catch (e) {
      setStatus(e instanceof FreePdfError ? `Could not save (${e.code})` : "Could not save");
    }
  }

  // The user pressed Save in the viewer: upload the whole PDF.
  async function uploadPdf(data: Uint8Array) {
    await fetch(`/api/contracts/${contractId}/pdf`, {
      method: "PUT",
      headers: { "Content-Type": "application/pdf" },
      body: data,
    });
    setStatus("PDF saved");
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
      <header style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 16px" }}>
        <span>{count} comments</span>
        <button onClick={saveComments}>Save comments</button>
        <span>{status}</span>
      </header>
      <FreePdfViewer
        ref={ref}
        baseUrl="/free-pdf-sdk/"
        mode="annotate"
        features={{ measurement: false }}
        disabledElements={["download"]}
        author={userName}
        document={{ url: `/api/contracts/${contractId}/pdf` }}
        onDocumentLoaded={() => void restoreComments()}
        onAnnotationsChanged={({ count }) => setCount(count)}
        onSaveRequested={({ data }) => void uploadPdf(data)}
        onError={({ code, message }) => setStatus(`${code}: ${message}`)}
        style={{ flex: 1 }}
      />
    </div>
  );
}

Notes on the example:

  • onDocumentLoaded can fire before the ref's viewer() is set for the first document, so restoreComments waits with whenReady().
  • annotate mode includes comments, markup, shapes, ink, stamps, measurement, Fill & Sign, undo history and Save, and nothing that edits pages or content. importXfdf and deleteAllAnnotations need an annotation feature: without one (view or organize mode, say) they reject with READ_ONLY. See Annotations and XFDF.
  • XFDF carries the comments only. To keep form fields, flattened content or page changes, save the PDF itself with viewer.save() (or from onSaveRequested).

Next.js (App Router)

The viewer runs only in the browser, so render it from a Client Component. Put the viewer folder in public/:

bash
npx free-pdf-sdk copy-viewer public/free-pdf-sdk

Mark the component that uses FreePdfViewer with "use client":

tsx
// app/contracts/[id]/ContractViewer.tsx
"use client";

import { FreePdfViewer } from "free-pdf-sdk/react";

export default function ContractViewer({ id }: { id: string }) {
  return (
    <FreePdfViewer
      baseUrl="/free-pdf-sdk/"
      mode="review"
      document={{ url: `/api/contracts/${id}/pdf` }}
      style={{ height: "calc(100vh - 64px)" }}
    />
  );
}
tsx
// app/contracts/[id]/page.tsx (a Server Component)
import ContractViewer from "./ContractViewer";

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <ContractViewer id={id} />;
}

On the server the component renders its empty container; the viewer mounts in the browser.

To keep the component out of the server render entirely (and load its code only when the page needs it), import it with next/dynamic and ssr: false. In the App Router, ssr: false is only allowed inside a Client Component, so wrap it:

tsx
// app/contracts/[id]/LazyContractViewer.tsx
"use client";

import dynamic from "next/dynamic";

const ContractViewer = dynamic(() => import("./ContractViewer"), {
  ssr: false,
  loading: () => <p>Loading viewer…</p>,
});

export default ContractViewer;

Then render <LazyContractViewer id={id} /> from the page. If your app uses a basePath, include it in baseUrl (for example /docs-app/free-pdf-sdk/).

StrictMode

In development, <StrictMode> mounts components, unmounts them and mounts them again. The component handles this: the first viewer is destroyed before it is ready and its events are dropped, so your handlers only hear from the second one. You may briefly see two iframes in the development tools. Production builds mount once.

Things to know

  • Unmounting the component destroys the viewer; pending calls reject with DESTROYED.
  • If a document can't be opened, onDocumentError fires and the viewer keeps running: set another document (or a password with the same document) to try again.
  • The viewer is an iframe on your origin that talks to the page over postMessage. PDFs are processed in the browser with WebAssembly and are not uploaded anywhere by the SDK. It needs WebGL; if it can't start, onError reports TIMEOUT (usually a wrong baseUrl) or INTERNAL (for example without WebGL).
  • The component is a thin layer over the core API; see Vanilla JavaScript for FreePdf.init itself.