useLivePreviewDocument() subscribes to the admin's updates and returns the
merged document, so your components re-render with it. Same shape as Payload's
own useLivePreview, with this package's merge underneath.
This is the other half of the package, not a replacement for it. The DOM runtime patches server-rendered markup; a hook gives React the new document and lets its reconciler update the client-owned tree. A stable component identity preserves component and DOM state. A changed type, key or position can cause a remount or replacement; the replaced node loses its state. Which owner to use, and why one page may use both: renderers.md and the caveat below.
npm install payload-live-previewreact is an optional peer this package does not install. The ./react entry
imports it at module scope, as a hook must; ./nextjs loads react, and
react-dom/server for a fragment endpoint, only at the first render that needs
it. The published ./react file starts with 'use client'.
The Payload admin also has to know which URL of your app to show for each document; the config plugin in payload.md sets that up.
'use client';
import { useLivePreviewDocument } from 'payload-live-preview/react';
export function PagePreview({ page }: { page: Page }) {
const { data, isLoading, status, error } = useLivePreviewDocument<Page>({
serverURL: process.env.NEXT_PUBLIC_PAYLOAD_URL!,
allowedOrigins: [process.env.NEXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!],
initialData: page,
depth: 1,
});
return (
<article>
<h1>{data.title}</h1>
{data.subtitle !== undefined && <p className="lede">{data.subtitle}</p>}
{status === 'unavailable' && <p role="status">Preview paused: {error?.message}</p>}
</article>
);
}Both NEXT_PUBLIC_ values ship to the browser. They are origins, not
credentials; keep preview tokens and other secrets in server-only variables.
| Option | Default | What it does |
|---|---|---|
serverURL |
required | Payload origin the update is re-fetched from. A trailing slash is fine |
initialData |
required | The document the page rendered; returned until an update merges |
depth |
1 |
Population depth for the merge |
timeoutMs |
off | Milliseconds a merge may wait for the server before the update is 'unavailable' |
apiRoute |
/api |
REST route prefix |
allowedOrigins |
— | Admin origins allowed to post updates |
eventSourcePolicy |
'parent-or-opener' |
'any' accepts a message from any window at an allowed origin |
enableReferrerDetection |
false |
Trust document.referrer as an origin source |
enableLocalhostMatching |
true |
Match localhost origins in development |
target |
the global window |
Window to listen on |
document |
— | The document this hook previews: { collection, id } or { global }, with an optional locale; only its updates merge |
A hook instance owns one document session. A fresh initialData object on an
ordinary re-render does not replace data that already merged. A changed value
of any other option except fetchFn (an array of allowedOrigins is compared
by its entries) replaces the session with a new one that starts from the
current initialData, so merged data is dropped. If the same
component can move from one CMS document to another, remount it with a key that
includes the document owner and id. This releases the old session and starts a
new one from the new initialData. On Payload 2 that session starts without a
field schema, and Payload 2.32.3 sends the schema only with its first message
to the preview: the next update reports 'unavailable' and reads nothing until
the admin page is loaded again
(interop.md). A read the old session
started can still answer after that, for example when a custom fetchFn (an
option meant for tests) ignores the abort signal. The answer changes nothing:
the new session keeps what it has and starts no read for it.
The return value is { data, isLoading, status, error, revision }. data and
isLoading are Payload's two names. isLoading is true until an update
settles, turns true again with every update it accepts, and is false once the
newest one has merged or failed to. status is 'idle' before the first update,
'live' when the newest one merged, 'unavailable' when it did not; error says
why, and only then. revision says which of the hook's messages data came
from: 0 for initialData, unchanged when a merge fails.
status describes the merge. React paints data in its own commit, so a
component that must know the editor sees a revision reads revision in an
effect, which runs after that commit:
useEffect(() => {
reportPainted(revision);
}, [revision]);data is the document as Payload's REST API returned it. A media document's url
is a path on Payload's own origin (/api/media/file/a.png) unless its config sets
serverURL, so a page on another origin resolves it first:
new URL(data.image.url, serverURL). The DOM runtime does this for its upload
and image bindings (Media URLs); the hook leaves it
to your component.
@payloadcms/live-preview is Payload's framework-independent message and merge
client; @payloadcms/live-preview-react wraps it for a React app. The official
wrapper is a natural choice when that app owns the document: one hook, no DOM
attributes. What follows is not an argument against it. It records five client
cases where this package's session and Payload's base client received the same
input.
All five cases run twice in this repository — against the session this hook uses
in document-session.test.ts,
and against the lockfile's exact @payloadcms/live-preview 3.88.0 in
payload-hook-comparison.test.ts,
which imports the installed package rather than describing it. Both run in
npm run test:unit, which CI runs on every push to main and every pull
request. These are client-session tests, not React rendering or tarball
consumer tests.
The scheduled npm run test:upstream-findings is a separate drift probe. It
runs seven low-level observations against the requested registry version; its
case set overlaps this table but is not identical, and it does not exercise the
React wrapper.
| Measured with | @payloadcms/live-preview 3.88.0 |
This hook |
|---|---|---|
serverURL https://cms.example.com/, message from https://cms.example.com |
no request at all; every callback hands back the document the page started on | POST https://cms.example.com/api/pages/1 |
| Two messages, the first answered after 40 ms, the second after 5 ms | callbacks in the order NEW, OLD — the older document is the one that stays |
data is the newer one, and still is after the slow answer lands |
HTTP 403 with Payload's error body, {"errors":[{"message":…}]} |
the callback receives that object; the document's fields are gone | data unchanged, status: 'unavailable', error names the 403 |
Two previews on one page, documents 1 and 2 |
the second one's merge fetches pages/1 — previousData is module-level |
the merge for 2 fetches pages/2; each hook keeps its own state, requests and errors (ADR 0002), and both follow the document the admin sends unless each names its document |
fetch rejecting with TypeError: Failed to fetch |
the rejection escapes the async listener | last good document kept, status: 'unavailable', error names it |
3.0 requires document (ADR 0007 ledger, row 26). Without it, both follow the
document the admin sends: a hook takes every
update from an allowed admin window, whichever document its initialData came
from. On a page that previews document 1, a second hook started from document
2 shows document 1 after the first update. Given
document: { collection: 'pages', id: 2 }, that hook leaves the update for
pages/1 alone: it sends no request and keeps its state. { global: 'header' }
names a global, and locale also leaves alone an update the admin sends for
another locale. An update that does not say which document it is for is left
alone as well, with one warning per hook in development: Payload 2 sends no
collection or global slug, so leave document out on Payload 2, and a create
screen has no id yet. A changed document replaces the session, like the other
options. Without document, give the hook to the component that renders the
document being edited, and render other documents from the data your server
loaded.
The failed update is not retried: this hook reports it and catches up on the
next message. Without timeoutMs, a merge request that never answers is not a
failure: it has no deadline, and the hook stays isLoading with its last
document. With timeoutMs, the update becomes unavailable once that many
milliseconds pass, and an answer that comes later is not read
(options.md). On Payload 3 the hook sends one merge request per accepted
message, at any typing pace: 18 requests for an 18-keystroke burst. The DOM
runtime asks less. It sends none for an edit that moves no populated value (the
first message on a page that reads one, and the first value of a field that held
none, still ask);
for the others it sends one request when a burst of updates starts and one when
it ends, a burst being updates less than debounceMs (50 ms by default) apart.
npm run test:interaction records 0 requests for a plain text field and 2 for
rich text and for a relationship over 18 keystrokes, 30 ms apart.
Payload 3 uses a POST to the REST API with
X-Payload-HTTP-Method-Override: GET. It returns the stored document with the
unsaved values applied and relationships populated.
Payload 2 sends fieldSchemaJSON and raw form values. The session retains
that accepted schema for later messages that omit it. Scalars settle without
a request; raw relationship IDs are grouped into bounded collection GET
requests with the configured depth and message locale. Those reads include
the editor's credentials and never send the method-override POST.
An invalid or incomplete population keeps the last good document and reports
'unavailable', and so does a read that Payload refuses. When the editor's
session ends in the middle of a preview, Payload 2.32.3 answers the next read
with 403: error names it, data and revision stay, and the first update
after a new sign-in is 'live' again. The DOM runtime uses the same merger
(ADR 0010).
The real-admin runs of the hook and the composable (Payload 2.32.3 and 3.92.1)
use development bundles; a production build of both is server-rendered,
hydrated and taken through its lifecycle against the mock admin (tests/e2e/specs/react-vue-tarball.spec.ts).
Origin trust is this package's, not the admin's word: a message is accepted only from an allowed origin, and by default only from the window that framed or opened the page (authorization.md for the wider model).
A render is not a remount. React preserves component state and usually the underlying DOM node while its type, key and position remain stable. It can still write controlled values, and a conditional branch, changed key or changed type can replace the node. A remount loses state held by that component or DOM node, including an uncontrolled value, focus or an inner scroll position.
The DOM runtime has a different ownership model. A scalar binding writes the existing element directly. A fragment or route render is morphed: compatible, paired live nodes retain their properties, while incompatible or unpaired nodes are replaced and lose them. Direct patches cannot create markup the page did not render; React can render conditional markup from the new document. The two approaches are complementary:
- Bindings (
data-payload-field) for server-rendered regions. - Fragments (
data-payload-fragment) when a region needs its own logic rendered on the server (hybrid.md). - This hook inside a client component that owns its subtree anyway — mark
its root
data-payload-islandso the runtime leaves it alone (interop.md).
<LivePreviewRouteRefresh refresh={...} /> hands the DOM runtime the host
router's refresh, so a route update is a re-render React performs rather than
server HTML this package morphs over the reconciler's nodes. It renders
nothing, registers while it is mounted, and gives the registration back on
unmount; without it the route strategy fetches and morphs as before. Wiring for
Next's App Router: nextjs.md. Outside React the same seam is
registerRouteRefresh(): give it a function whose promise settles once the
host has committed the fresh render, as the SvelteKit and Nuxt recipes do. A
function that returns nothing is taken as already committed; with debug the
runtime says once that it could not wait (LP0810), and 3.0 requires the
promise (ADR 0028).
The same composable, the same session, the same five cases: vue.md.
useLivePreviewDocument renders initialData on the server: there is no window
and no message, so the first client render matches it and hydration is quiet.
The subscription starts after mount.