EmbedPDF

Metadata

A PDF carries information about itself: a title, an author, when it was made. Register metadataPlugin() to show it in your UI and let people change it:

title-field.tsx
import { useMetadata, useMetadataState } from '@embedpdf/react/metadata';

export function TitleField() {
  const { update, canUpdate } = useMetadata();
  const { metadata } = useMetadataState();

  return (
    <label>
      Title
      <input
        defaultValue={metadata?.title ?? ''}
        disabled={!canUpdate()}
        onBlur={(event) => update({ title: event.target.value })}
      />
    </label>
  );
}

What useMetadataState() gives you updates by itself: after your own changes, and on a shared document, after someone else’s. Nothing is read when a document opens: the metadata loads the first time something asks for it, so a document nobody shows the properties of costs nothing.

Read#

metadata has every standard field. A field the file doesn’t set is null:

FieldWhat it is
titleThe document’s title
authorWho wrote it
subjectWhat it’s about
keywordsKeywords, as one string (often separated by commas)
creatorThe app the document was made in
producerThe app that turned it into a PDF
createdAtWhen it was created, as an ISO date
modifiedAtWhen it was last changed, as an ISO date
trappedA print-shop setting: 'true', 'false' or 'unknown', never null

status says whether the metadata hasn’t been asked for yet ('idle'), is loading ('loading'), is there ('ready'), or is not allowed ('forbidden'). Reading the state is what asks: useMetadataState() loads the metadata of the document it reads, so its first answer is 'loading'. Show that while it lasts, the way you show any other load:

const title = status === 'ready' ? (metadata?.title ?? 'Untitled') : 'Loading…';

From code, ensureLoaded() reads it and resolves once it’s there:

await metadata.ensureLoaded();
const author = metadata.getSnapshot()?.author;

Every field useMetadataState() gives you, and every status, is listed under State.

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { metadataPlugin, useMetadataState } from '@embedpdf/react/metadata';
import { localEngine } from '@embedpdf/engine';

import './properties.css';

const engine = localEngine();
const plugins = [metadataPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const FIELDS = [
  ['title', 'Title'],
  ['author', 'Author'],
  ['subject', 'Subject'],
  ['keywords', 'Keywords'],
  ['creator', 'Made in'],
  ['producer', 'Turned into a PDF by'],
  ['createdAt', 'Created'],
  ['modifiedAt', 'Last changed'],
  ['trapped', 'Trapped'],
] as const;

// Dates are ISO strings: show them in the reader's own format.
const DATES: ReadonlySet<string> = new Set(['createdAt', 'modifiedAt']);
const show = (field: string, value: string | null) => {
  if (value === null) return null;
  return DATES.has(field) ? new Date(value).toLocaleString() : value;
};

// The document's properties, the way a "Document properties" dialog shows them.
function Properties() {
  const { metadata, status } = useMetadataState();

  return (
    <section className="panel">
      <header className="panel-header">
        <h3 className="panel-title">Document properties</h3>
        <span className="status" data-status={status}>
          {status}
        </span>
      </header>
      <dl className="properties">
        {FIELDS.map(([field, label]) => {
          const value = metadata ? show(field, metadata[field]) : null;
          return (
            <div className="property" key={field}>
              <dt>{label}</dt>
              <dd className={value === null ? 'unset' : undefined}>{value ?? 'not set'}</dd>
            </div>
          );
        })}
      </dl>
    </section>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Properties />
      </DocumentGate>
    </Viewer>
  );
}

Change#

update() changes the fields you pass, and null removes one:

await metadata.update({
  title: 'Q2 Proposal (final)',
  author: 'Dana Smith',
  subject: null,
});

It resolves with { metadata }: the fields after the change. The change is part of the document, kept when it’s downloaded.

Loading live preview…

import { useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { DocumentMetadata, OpenInput } from '@embedpdf/react/runtime';
import {
  metadataPlugin,
  useMetadata,
  useMetadataEvent,
  useMetadataState,
} from '@embedpdf/react/metadata';
import { localEngine } from '@embedpdf/engine';

import './edit.css';

const engine = localEngine();
const plugins = [metadataPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const EDITABLE = ['title', 'author', 'subject', 'keywords'] as const;
type Draft = Record<(typeof EDITABLE)[number], string>;

// A properties form: the inputs start with what the file says, and Save writes them back.
function PropertiesForm({ initial }: { initial: DocumentMetadata }) {
  const metadata = useMetadata();
  const modifiedAt = useMetadataState((state) => state.metadata?.modifiedAt ?? null);
  const [draft, setDraft] = useState<Draft>({
    title: initial.title ?? '',
    author: initial.author ?? '',
    subject: initial.subject ?? '',
    keywords: initial.keywords ?? '',
  });
  const [changes, setChanges] = useState<string[]>([]);

  // Every change to the standard fields, by you or anyone else.
  useMetadataEvent(
    (capability) => capability.onUpdated,
    ({ changedKeys, origin }) =>
      setChanges((list) => [
        `${changedKeys.join(', ')} changed ${origin.kind === 'local' ? 'here' : 'elsewhere'}`,
        ...list,
      ]),
  );

  const canUpdate = metadata.canUpdate();
  // An empty field removes it: null, not ''.
  const save = () =>
    metadata.update({
      title: draft.title.trim() || null,
      author: draft.author.trim() || null,
      subject: draft.subject.trim() || null,
      keywords: draft.keywords.trim() || null,
    });

  return (
    <div className="layout">
      <form
        className="form"
        onSubmit={(event) => {
          event.preventDefault();
          void save();
        }}
      >
        {EDITABLE.map((key) => (
          <label className="row" key={key}>
            <span className="name">{key}</span>
            <input
              className="field"
              value={draft[key]}
              disabled={!canUpdate}
              placeholder="not set"
              onChange={(event) => setDraft({ ...draft, [key]: event.target.value })}
            />
          </label>
        ))}
        <div className="actions">
          <button type="submit" className="button primary" disabled={!canUpdate}>
            Save
          </button>
          <button
            type="button"
            className="button"
            disabled={!canUpdate}
            onClick={() => metadata.update({ modifiedAt: new Date() })}
          >
            Set the modified date to now
          </button>
        </div>
      </form>
      <section className="changes" aria-live="polite">
        <h3 className="changes-title">Changes</h3>
        <p className="modified">
          Last changed: {modifiedAt ? new Date(modifiedAt).toLocaleString() : 'not set'}
        </p>
        {changes.length === 0 ? (
          <p className="empty">Edit a field and save.</p>
        ) : (
          <ul className="log">
            {changes.map((change, index) => (
              <li key={changes.length - index}>{change}</li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}

// The form starts once the properties have been read from the file.
function PropertiesEditor() {
  const fields = useMetadataState((state) => state.metadata);
  return fields ? <PropertiesForm initial={fields} /> : <p className="loading">Reading…</p>;
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <PropertiesEditor />
      </DocumentGate>
    </Viewer>
  );
}

Your own fields#

Keep your own values in the document, such as a contract number or who reviewed it. PDF apps show them as custom properties:

custom-fields.tsx
import { useMetadataState } from '@embedpdf/react/metadata';

export function ContractNumber() {
  const { custom } = useMetadataState(); // { contractId: 'C-2026-114', reviewedBy: 'dana' }

  return <span>Contract {custom?.contractId}</span>;
}
await metadata.custom.update({ reviewedBy: 'lee', draftOwner: null });

A string sets a field, null removes it, and fields you leave out stay as they are. A field name is 1 to 127 plain characters and can’t be a standard field’s name.

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate, isPluginError } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { metadataPlugin, useMetadata, useMetadataState } from '@embedpdf/react/metadata';
import { localEngine } from '@embedpdf/engine';

import './custom-fields.css';

const engine = localEngine();
const plugins = [metadataPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Your own fields, kept in the document: add one, change one, remove one.
function CustomFields() {
  const metadata = useMetadata();
  const { custom } = useMetadataState();
  const [name, setName] = useState('');
  const [value, setValue] = useState('');
  const [error, setError] = useState<string | null>(null);

  // Two fields of our own, written on load. Setting the same values again changes nothing.
  useEffect(() => {
    void metadata.custom.update({ contractId: 'C-2026-114', reviewedBy: 'dana' });
  }, [metadata]);

  const canUpdate = metadata.canUpdate();
  const add = async () => {
    try {
      await metadata.custom.update({ [name.trim()]: value });
      setName('');
      setValue('');
      setError(null);
    } catch (failure) {
      // A name the PDF can't take, such as one of the standard fields.
      if (isPluginError(failure, 'invalid-input')) setError(failure.message);
      else throw failure;
    }
  };

  return (
    <section className="panel">
      <table className="fields">
        <thead>
          <tr>
            <th>Name</th>
            <th>Value</th>
            <th aria-label="Remove" />
          </tr>
        </thead>
        <tbody>
          {Object.entries(custom ?? {}).map(([key, text]) => (
            <tr key={key}>
              <td className="key">{key}</td>
              <td>{text}</td>
              <td>
                <button
                  type="button"
                  className="button quiet"
                  disabled={!canUpdate}
                  // null removes a field; the others stay as they are.
                  onClick={() => metadata.custom.update({ [key]: null })}
                >
                  Remove
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <form
        className="add"
        onSubmit={(event) => {
          event.preventDefault();
          void add();
        }}
      >
        <input
          className="field"
          aria-label="Name"
          placeholder="Name, such as approvedOn"
          value={name}
          onChange={(event) => setName(event.target.value)}
        />
        <input
          className="field"
          aria-label="Value"
          placeholder="Value"
          value={value}
          onChange={(event) => setValue(event.target.value)}
        />
        <button type="submit" className="button" disabled={!canUpdate || !name.trim()}>
          Add
        </button>
      </form>
      {error && <p className="error">{error}</p>}
    </section>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <CustomFields />
      </DocumentGate>
    </Viewer>
  );
}

Methods#

Everything useMetadata() gives you.

MethodWhat it does
update(changes)Changes the standard fields you pass; null removes one. Resolves { metadata }, the fields after the change. Change
custom.update(changes)Changes your own fields: a string sets one, null removes it, the rest stay. Resolves { custom }. Your own fields
ensureLoaded()Reads the standard fields and your own from the file, unless they’re loaded or loading. Resolves once they’re there. Read
refresh(), custom.refresh()Reads the standard fields, or your own, from the file again. Rarely needed: they stay up to date by themselves.
canUpdate()Whether changing the standard fields and your own ones is allowed. Permissions

Every method that returns a promise also takes a signal in its options, to cancel it.

State#

Everything useMetadataState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

FieldWhat it isRead once
metadataEvery standard field, as listed under Read, or null until it has loadedgetSnapshot()
customYour own fields, as { name: value }, or null until they have loadedcustom.getSnapshot()
status'idle' until something asks for it, then 'loading', 'ready', 'forbidden' or 'error'getStatus(), or custom.getStatus() for your own fields

Events#

title-notice.tsx
import { useState } from 'react';
import { useMetadataEvent } from '@embedpdf/react/metadata';

export function TitleNotice() {
  const [notice, setNotice] = useState('');

  useMetadataEvent(
    (metadata) => metadata.onUpdated,
    ({ metadata, origin }) => {
      if (origin.kind === 'remote') setNotice(`The title is now "${metadata.title}"`);
    },
  );

  return <p role="status">{notice}</p>;
}
EventFires whenCarries
onUpdatedThe standard fields changed, by you or by someone elsemetadata, previous, changedKeys, origin
onResyncedThe standard fields were loaded from the file: the first time they’re asked for, after refresh(), or to catch up after a lost connectionmetadata
custom.onUpdatedYour own fields changedcustom, previous, changedKeys, origin
custom.onResyncedYour own fields were loaded from the filecustom

changedKeys names the fields that changed, and previous has the values before.

Permissions#

PermissionWhat it allowsCheck
doc.openReading every fieldnone: it opened
doc.metadata.modifyChanging the standard fields and your own onesmetadata.canUpdate()

Good to know#

The modified date#

Changing a property doesn’t change modifiedAt for you. Set it yourself when you want it to move: update({ modifiedAt: new Date() }).

Dates#

Dates are ISO strings, with the time zone the PDF gives, so they survive JSON. Write a date as an ISO string or a Date. See Metadata for the details.

Was this page helpful?

Your feedback goes directly to the documentation team.