Implementing Live Preview in your app

While using Live Preview, the Admin panel emits a new window.postMessage event every time a change is made to the document. Your front-end application can listen for these events and re-render accordingly.

Wiring your front-end into Live Preview is easy. If your front-end application is built with React, Next.js, Vue or Nuxt.js, use the useLivePreview hook that Payload provides. In the future, all other major frameworks like Svelte will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See building your own hook for more information.

By default, all hooks accept the following args:

PathDescription
serverURL *The URL of your Payload server.
initialDataThe initial data of the document. The live data will be merged in as changes are made.
depthThe depth of the relationships to fetch. Defaults to 0.
apiRouteThe path of your API route as defined in routes.api. Defaults to /api.

* An asterisk denotes that a property is required.

And return the following values:

PathDescription
dataThe live data of the document, merged with the initial data.
isLoadingA boolean that indicates whether or not the document is loading.

React

If your front-end application is built with React or Next.js, you can use the useLivePreview hook that Payload provides.

First, install the @payloadcms/live-preview-react package:

1
npm install @payloadcms/live-preview-react

Then, use the useLivePreview hook in your React component:

1
'use client';
2
import { useLivePreview } from '@payloadcms/live-preview-react';
3
import { Page as PageType } from '@/payload-types'
4
5
// Fetch the page in a server component, pass it to the client component, then thread it through the hook
6
// The hook will take over from there and keep the preview in sync with the changes you make
7
// The `data` property will contain the live data of the document
8
export const PageClient: React.FC<{
9
page: {
10
title: string
11
}
12
}> = ({ page: initialPage }) => {
13
const { data } = useLivePreview<PageType>({
14
initialData: initialPage,
15
serverURL: PAYLOAD_SERVER_URL,
16
depth: 2,
17
})
18
19
return (
20
<h1>{data.title}</h1>
21
)
22
}

Vue

If your front-end application is built with Vue 3 or Nuxt 3, you can use the useLivePreview composable that Payload provides.

First, install the @payloadcms/live-preview-vue package:

1
npm install @payloadcms/live-preview-vue

Then, use the useLivePreview hook in your Vue component:

1
<script setup lang="ts">
2
import type { PageData } from '~/types';
3
import { defineProps } from 'vue';
4
import { useLivePreview } from '@payloadcms/live-preview-vue';
5
6
// Fetch the initial data on the parent component or using async state
7
const props = defineProps<{ initialData: PageData }>();
8
9
// The hook will take over from here and keep the preview in sync with the changes you make.
10
// The `data` property will contain the live data of the document only when viewed from the Preview view of the Admin UI.
11
const { data } = useLivePreview<PageData>({
12
initialData: props.initialData,
13
serverURL: "<PAYLOAD_SERVER_URL>",
14
depth: 2,
15
});
16
</script>
17
18
<template>
19
<h1>{{ data.title }}</h1>
20
</template>

Building your own hook

No matter what front-end framework you are using, you can build your own hook using the same underlying tooling that Payload provides.

First, install the base @payloadcms/live-preview package:

1
npm install @payloadcms/live-preview

This package provides the following functions:

PathDescription
subscribeSubscribes to the Admin panel's window.postMessage events and calls the provided callback function.
unsubscribeUnsubscribes from the Admin panel's window.postMessage events.
readySends a window.postMessage event to the Admin panel to indicate that the front-end is ready to receive messages.

The subscribe function takes the following args:

PathDescription
callback *A callback function that is called with data every time a change is made to the document.
serverURL *The URL of your Payload server.
initialDataThe initial data of the document. The live data will be merged in as changes are made.
depthThe depth of the relationships to fetch. Defaults to 0.

With these functions, you can build your own hook using your front-end framework of choice:

1
import { subscribe, unsubscribe } from '@payloadcms/live-preview';
2
3
// To build your own hook, subscribe to Live Preview events using the`subscribe` function
4
// It handles everything from:
5
// 1. Listening to `window.postMessage` events
6
// 2. Merging initial data with active form state
7
// 3. Populating relationships and uploads
8
// 4. Calling the `onChange` callback with the result
9
// Your hook should also:
10
// 1. Tell the Admin panel when it is ready to receive messages
11
// 2. Handle the results of the `onChange` callback to update the UI
12
// 3. Unsubscribe from the `window.postMessage` events when it unmounts

Here is an example of what the same useLivePreview React hook from above looks like under the hood:

1
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
2
import { useCallback, useEffect, useState, useRef } from 'react'
3
4
export const useLivePreview = <T extends any>(props: {
5
depth?: number
6
initialData: T
7
serverURL: string
8
}): {
9
data: T
10
isLoading: boolean
11
} => {
12
const { depth = 0, initialData, serverURL } = props
13
const [data, setData] = useState<T>(initialData)
14
const [isLoading, setIsLoading] = useState<boolean>(true)
15
const hasSentReadyMessage = useRef<boolean>(false)
16
17
const onChange = useCallback((mergedData) => {
18
// When a change is made, the `onChange` callback will be called with the merged data
19
// Set this merged data into state so that React will re-render the UI
20
setData(mergedData)
21
setIsLoading(false)
22
}, [])
23
24
useEffect(() => {
25
// Listen for `window.postMessage` events from the Admin panel
26
// When a change is made, the `onChange` callback will be called with the merged data
27
const subscription = subscribe({
28
callback: onChange,
29
depth,
30
initialData,
31
serverURL,
32
})
33
34
// Once subscribed, send a `ready` message back up to the Admin panel
35
// This will indicate that the front-end is ready to receive messages
36
if (!hasSentReadyMessage.current) {
37
hasSentReadyMessage.current = true
38
39
ready({
40
serverURL
41
})
42
}
43
44
// When the component unmounts, unsubscribe from the `window.postMessage` events
45
return () => {
46
unsubscribe(subscription)
47
}
48
}, [serverURL, onChange, depth, initialData])
49
50
return {
51
data,
52
isLoading,
53
}
54
}

Example

For a working demonstration of this, check out the official Live Preview Example. There you will find examples of various front-end frameworks and how to integrate each one of them, including:

Troubleshooting

Relationships and/or uploads are not populating

If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure CORS to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure CSRF to allow cookies to be sent between the two domains. For example:

1
// payload.config.ts
2
{
3
// ...
4
// If your site is running on a different domain than your Payload server,
5
// This will allows requests to be made between the two domains
6
cors: {
7
[
8
'http://localhost:3001' // Your front-end application
9
],
10
},
11
// If you are protecting resources behind user authentication,
12
// This will allow cookies to be sent between the two domains
13
csrf: {
14
[
15
'http://localhost:3001' // Your front-end application
16
],
17
},
18
}

Relationships and/or uploads disappear after editing a document

It is possible that either you are setting an improper depth in your initial request and/or your useLivePreview hook, or they're mismatched. Ensure that the depth parameter is set to the correct value, and that it matches exactly in both places. For example:

1
// Your initial request
2
const { docs } = await payload.find({
3
collection: 'pages',
4
depth: 1, // Ensure this is set to the proper depth for your application
5
where: {
6
slug: {
7
equals: 'home',
8
}
9
}
10
})
1
// Your hook
2
const { data } = useLivePreview<PageType>({
3
initialData: initialPage,
4
serverURL: PAYLOAD_SERVER_URL,
5
depth: 1, // Ensure this matches the depth of your initial request
6
})

Iframe refuses to connect

If your front-end application has set a Content Security Policy (CSP) that blocks the Admin Panel from loading your front-end application, the iframe will not be able to load your site. To resolve this, you can whitelist the Admin Panel's domain in your CSP by setting the frame-ancestors directive:

1
frame-ancestors: "self" localhost:* https://your-site.com;
Next

Access Control