---
title: "Live Preview — Client-Side Integration"
aliases: [live-preview-client, useLivePreview, live-preview-react, live-preview-vue]
tags: [payloadcms, live-preview, react, vue, frontend]
sources: [raw/live-preview__client.md]
created: 2026-05-15
updated: 2026-05-15
---

# Live Preview — Client-Side Integration

> For apps using **React Server Components** (Next.js App Router), use [[wiki/payloadcms/live-preview|server-side Live Preview]] instead — it's simpler and more performant. Client-side is for Pages Router, SPAs, Vue, Nuxt.

The Admin Panel emits a `window.postMessage` event on every form change. The client subscribes, merges incoming data with initial data (including relationship population), and updates state.

## Hook Args (all frameworks)

| Arg | Required | Description |
|-----|----------|-------------|
| `serverURL` | ✓ | URL of your Payload server |
| `initialData` | | Initial document data; live data merges on top |
| `depth` | | Relationship depth to populate. **Must match your initial fetch depth exactly.** Default: `0` |
| `apiRoute` | | Override if `routes.api` is non-default. Default: `/api` |

## Hook Return Values

| Value | Description |
|-------|-------------|
| `data` | Live document data, merged with `initialData` |
| `isLoading` | `true` until first message received from Admin Panel |

## React

Install:
```bash
npm install @payloadcms/live-preview-react
```

```tsx
'use client'
import { useLivePreview } from '@payloadcms/live-preview-react'
import { Page as PageType } from '@/payload-types'

export const PageClient: React.FC<{ page: PageType }> = ({ page: initialPage }) => {
  const { data, isLoading } = useLivePreview<PageType>({
    initialData: initialPage,
    serverURL: PAYLOAD_SERVER_URL,
    depth: 2, // must match initial fetch depth
  })
  return <h1>{data.title}</h1>
}
```

- Use only in `'use client'` components — **not** in React Server Components
- Pass fetched page data from parent server component as `initialPage` prop

## Vue 3 / Nuxt 3

Install:
```bash
npm install @payloadcms/live-preview-vue
```

```vue
<script setup lang="ts">
import { useLivePreview } from '@payloadcms/live-preview-vue'
const props = defineProps<{ initialData: PageData }>()
const { data } = useLivePreview<PageData>({
  initialData: props.initialData,
  serverURL: '<PAYLOAD_SERVER_URL>',
  depth: 2,
})
</script>
<template><h1>{{ data.title }}</h1></template>
```

## Building Your Own Hook

Install the base package:
```bash
npm install @payloadcms/live-preview
```

### API

| Function | Description |
|----------|-------------|
| `subscribe({ callback, serverURL, initialData, depth })` | Subscribes to Admin Panel `postMessage` events; calls `callback(mergedData)` on each change |
| `unsubscribe(subscription)` | Cleans up the subscription |
| `ready({ serverURL })` | Signals Admin Panel that frontend is ready to receive messages |
| `isLivePreviewEvent(event)` | Checks if a `MessageEvent` comes from the Admin Panel (debounced form state) |

### Full React Hook Implementation

```tsx
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
import { useCallback, useEffect, useState, useRef } from 'react'

export const useLivePreview = <T>(props: {
  depth?: number
  initialData: T
  serverURL: string
}): { data: T; isLoading: boolean } => {
  const { depth = 0, initialData, serverURL } = props
  const [data, setData] = useState<T>(initialData)
  const [isLoading, setIsLoading] = useState(true)
  const hasSentReadyMessage = useRef(false)

  const onChange = useCallback((mergedData) => {
    setData(mergedData)
    setIsLoading(false)
  }, [])

  useEffect(() => {
    const subscription = subscribe({ callback: onChange, depth, initialData, serverURL })

    if (!hasSentReadyMessage.current) {
      hasSentReadyMessage.current = true
      ready({ serverURL })
    }

    return () => { unsubscribe(subscription) }
  }, [serverURL, onChange, depth, initialData])

  return { data, isLoading }
}
```

Key responsibilities of a custom hook:
1. Call `subscribe()` — handles postMessage listening, data merging, relationship population, callback invocation
2. Call `ready()` once — tells Admin Panel the iframe frontend is listening
3. Call `unsubscribe()` on unmount — prevents memory leaks

## Gotchas

### Relationships/uploads not populating
- Frontend and Payload on different domains → configure `cors` **and** `csrf` in `payload.config.ts`:
  ```ts
  cors: ['http://localhost:3001'],
  csrf: ['http://localhost:3001'],
  ```
- Applies to different ports and subdomains too

### Relationships disappear after editing
- `depth` in `useLivePreview` must **exactly match** the `depth` in your initial `payload.find()` call
  ```tsx
  // Initial fetch:
  await payload.find({ collection: 'pages', depth: 1, where: { slug: { equals: 'home' } } })
  // Hook:
  useLivePreview({ initialData, serverURL, depth: 1 }) // same depth!
  ```

### Iframe refuses to connect (CSP)
- Add to your frontend's Content Security Policy:
  ```
  frame-ancestors: "self" localhost:* https://your-payload-admin.com;
  ```

### Conditional / fragile UI
- Guard optional chain access: `data?.relatedPosts?.[0]?.title` — not `data.relatedPosts[0].title`
- Fields removed in Admin will result in `undefined` values; use optional chaining or defaults

## Key Takeaways

- **Client-side** = real-time updates on every keystroke via `window.postMessage`; use for Pages Router, SPAs, Vue/Nuxt
- **Server-side** = refreshes on save/autosave; use for Next.js App Router RSC (preferred when possible)
- `depth` in the hook must **exactly match** the initial data fetch depth — mismatch silently drops relationships
- Custom hooks must: subscribe → send `ready()` → unsubscribe on unmount
- Cross-domain setups require both `cors` and `csrf` config in Payload
- CSP `frame-ancestors` must whitelist the Admin Panel domain

## Related

- [[wiki/payloadcms/live-preview|Live Preview Overview]] — Admin Panel config, breakpoints, dynamic URL, server-side setup
- [[wiki/payloadcms/queries|Queries & Depth]] — `depth` parameter controls relationship auto-population
- [[wiki/payloadcms/authentication-cookies|Authentication Cookies & CSRF]] — cross-domain cookie/CSRF config
- [[wiki/payloadcms/configuration|Payload Config]] — `cors` option for cross-domain requests
