- Complete Guide to Payload CMS Live Preview with Next.js
Complete Guide to Payload CMS Live Preview with Next.js
Connect Next.js Draft Mode, Payload versions, and a postMessage bridge to build reliable, real-time live preview with…

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryLive Preview with Payload CMS and Next.js — A Complete Implementation Guide
Last updated: 2025 · Tested on Next.js 16.2, Payload CMS 3.x · By Matija
Live preview in Payload CMS requires connecting three independent mechanisms — Next.js Draft Mode, Payload's versioning system, and a postMessage bridge — into one continuous chain. This guide walks through every file you need to create, explains what each piece does and why the order matters, and covers the failure modes that silently break the whole system.
I put this together after implementing live preview on a production Payload + Next.js build and spending more time than I'd like to admit debugging a mismatch between cookie paths and redirect targets. The official docs cover the individual pieces; this article connects them into a working system from scratch.
What You're Actually Building
Before touching any code, understand the three mechanisms that make this work — because confusing them is the number one reason implementations fail.
Next.js Draft Mode is a server-side mechanism built into the App Router. When enabled for a browser session, every Server Component in that session opts out of all caching — "use cache", unstable_cache, fetch cache, ISR — everything. It works via a signed, HTTP-only cookie called __prerender_bypass. Once that cookie is present, any server component can read draftMode().isEnabled to know it is in a draft session.
Payload Versions + Drafts — when you enable versions.drafts on a collection, every save creates a new version record in the database rather than overwriting the published document. The autosave option makes Payload automatically save a draft version every N milliseconds while the editor is typing. This is what creates the near-real-time feel during live preview: the draft is continuously written to the database without the editor doing anything.
The postMessage Bridge — Payload's Live Preview renders your Next.js storefront inside an <iframe> within the admin panel. When a draft is autosaved, Payload sends a window.postMessage event from the parent frame to the iframe. The <RefreshRouteOnSave> component you mount on the preview page listens for that message and calls router.refresh(), which re-runs all Server Components on that page and shows the new draft data.
None of these three mechanisms know about each other. Your job is to connect them:
postMessage → router.refresh() → draftMode check → bypass cache → fetch draft → render
The full data flow looks like this:
Editor types a character
│
▼ (after autosave.interval ms)
Payload saves a draft version to the database
│
▼
Payload sends window.postMessage to the iframe: { type: 'payload-live-preview' }
│
▼
<RefreshRouteOnSave> receives the message
│
▼
router.refresh() is called
│
▼
Next.js re-runs all Server Components on the current page
│
▼
queryPageBySlug({ draft: true }) fetches the new draft from Payload
│
▼
Page re-renders with the updated content
The round-trip from keypress to visible update is typically 1–3 seconds: autosave interval (1.2s by default) + database write + Next.js server component re-run.
Prerequisites
- Next.js 15.x or 16.x with App Router
- Payload CMS 3.x (self-hosted, running inside the same Next.js app or as a separate service)
- TypeScript
- A Payload collection with at least a
slugfield
This guide assumes Payload is mounted at /cms inside your Next.js app. If your path is different, adjust every /cms/api/draft reference accordingly.
Step 1 — Install Dependencies
npm install @payloadcms/live-preview-react
That is the only new package. Everything else — draftMode, Route Handlers, Server Components — is built into Next.js.
Step 2 — Environment Variables
You need two variables. Add them to .env.local for development and to your hosting platform for production.
# .env.local
# Shared secret between Next.js and Payload.
# Must match the `secret` field in payload.config.ts exactly.
# Never expose this to the browser — no NEXT_PUBLIC_ prefix.
PAYLOAD_SECRET=a-long-random-string-change-this
# The public-facing origin of your Next.js app.
# Used by RefreshRouteOnSave to scope the postMessage listener.
# Needs NEXT_PUBLIC_ prefix because it's read by a client component.
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_SECRET has no NEXT_PUBLIC_ prefix because it is validated server-side only, inside the draft route handler. If you accidentally prefix it with NEXT_PUBLIC_, it gets embedded in the browser bundle and anyone can enable draft mode on your production site.
NEXT_PUBLIC_SERVER_URL needs the prefix because <RefreshRouteOnSave> is a 'use client' component. Client components cannot access process.env variables without the NEXT_PUBLIC_ prefix — those variables are never sent to the browser.
Step 3 — Configure Payload and Define Your Base Path
In your payload.config.ts, confirm that your secret field reads from the environment variable. Then create a constants file so the admin path is never hardcoded in multiple places.
// payload.config.ts
import { buildConfig } from 'payload'
export default buildConfig({
secret: process.env.PAYLOAD_SECRET || '',
// ... rest of your config
})
// src/payload/config/routes.ts
export const CMS_BASE_PATH = '/cms'
Every reference to the draft route URL imports from this file. Changing the path later means changing one line.
Step 4 — Build the Preview URL Generator
Payload calls this function to get the URL it should open when an editor clicks the "Preview" button or when the "Live Preview" panel initializes its iframe. Both scenarios must resolve to your draft route — which will enable draft mode and redirect to the real page.
// src/payload/utilities/generatePreviewUrl.ts
import { CMS_BASE_PATH } from '@/payload/config/routes'
interface GeneratePreviewUrlOptions {
basePath?: string // for collections nested under a URL prefix, e.g. 'careers'
locale?: string
}
/**
* Returns the base origin for a given locale.
* In dev: derived from NEXT_PUBLIC_SERVER_URL.
* In prod: can be overridden per locale with NEXT_PUBLIC_SERVER_URL_EN etc.
*/
const getBaseUrlForLocale = (locale?: string): string => {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'
const effectiveLocale = locale ?? 'de'
if (effectiveLocale === 'de') return serverUrl
// Production: explicit per-locale env var
const localeUrl = process.env[`NEXT_PUBLIC_SERVER_URL_${effectiveLocale.toUpperCase()}`]
if (localeUrl) return localeUrl
// Dev fallback: swap subdomain de.example.com → en.example.com
try {
const url = new URL(serverUrl)
const parts = url.hostname.split('.')
if (parts.length >= 3) {
parts[0] = effectiveLocale
url.hostname = parts.join('.')
return url.origin
}
} catch {}
return serverUrl
}
/**
* Builds the full draft activation URL.
*
* Result: https://example.com/cms/api/draft?slug=/my-page&secret=XXX
*
* The draft route will:
* 1. Validate the secret
* 2. Enable Next.js Draft Mode (sets a cookie)
* 3. Redirect the browser to the real page
*/
const buildPreviewUrl = (
slug: string,
basePath?: string,
locale?: string,
): string => {
if (!process.env.PAYLOAD_SECRET) {
throw new Error('PAYLOAD_SECRET is not set. Live preview will not work.')
}
// Derive the storefront path from the slug
let path: string
if (slug === 'home') {
path = '/'
} else if (basePath) {
path = `/${basePath}/${slug}`
} else {
path = `/${slug}`
}
const params = new URLSearchParams({
slug: path,
secret: process.env.PAYLOAD_SECRET,
})
const origin = getBaseUrlForLocale(locale)
// /cms/api/draft is where our Route Handler lives (Step 6)
return `${origin}${CMS_BASE_PATH}/api/draft?${params.toString()}`
}
/**
* Used by the Pages collection preview and livePreview.url callbacks.
*/
export const generatePagePreviewUrl = async (
doc: any,
options?: { locale?: string },
): Promise<string | null> => {
if (!doc?.slug) return null
return buildPreviewUrl(doc.slug, undefined, options?.locale)
}
/**
* Factory for collections that live under a URL prefix.
* e.g. createPreviewUrlGenerator('careers') → /careers/my-job-slug
*/
export const createPreviewUrlGenerator = (basePath: string) => {
return async (
doc: any,
options?: { locale?: string },
): Promise<string | null> => {
if (!doc?.slug) return null
return buildPreviewUrl(doc.slug, basePath, options?.locale)
}
}
The livePreview.url callback receives data rather than doc. In the live preview context, data is the live field state from the editor form — it may contain unsaved changes that haven't been written to the database yet. The iframe URL is only used to initially load the page; subsequent updates come via postMessage, not URL changes.
Step 5 — Configure the Collection
Three things are required in the collection config: admin.preview for the "Preview" button URL, admin.livePreview.url for the iframe URL, and versions.drafts to enable draft versions. Without versions.drafts, Payload hides the Live Preview button entirely — there is nothing to preview.
// src/payload/collections/content/pages/index.ts
import type { CollectionConfig } from 'payload'
import { generatePagePreviewUrl } from '@/payload/utilities/generatePreviewUrl'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
// "Preview" button — opens the draft route in a new browser tab.
// Payload calls this with (doc, { locale, token }).
preview: generatePagePreviewUrl,
// "Live Preview" panel — Payload renders an iframe with this URL.
// `data` is the current (possibly unsaved) field values.
// `locale` is the currently selected locale object ({ code: 'en', ... }).
livePreview: {
url: ({ data, locale }) =>
generatePagePreviewUrl(data, { locale: locale?.code }),
},
},
// REQUIRED: Without versions.drafts, there is nothing to preview.
// Payload will not show the Live Preview button unless drafts are enabled.
versions: {
drafts: {
autosave: {
// How long Payload waits after the last keystroke before saving.
// Lower = more responsive preview, higher = fewer database writes.
// 1200ms is a good default.
interval: 1200,
},
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
// ... your slug field configuration
},
{
name: 'layout',
type: 'blocks',
localized: true,
blocks: [
// ... your block definitions
],
},
],
}
Step 6 — The Draft Route Handler
This Route Handler is the bridge between Payload clicking a URL and Next.js entering draft mode. It validates the shared secret, calls draftMode().enable(), then redirects to the real page.
// src/app/(payload)/cms/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
// ── Security check ──────────────────────────────────────────────────────
// Never skip this. Without it, anyone can visit this URL and enable
// draft mode, then see unpublished content.
if (!process.env.PAYLOAD_SECRET || secret !== process.env.PAYLOAD_SECRET) {
return new Response('Invalid token', { status: 401 })
}
// ── Parameter validation ─────────────────────────────────────────────────
if (!slug) {
return new Response('Missing slug parameter', { status: 400 })
}
// ── Enable Draft Mode ────────────────────────────────────────────────────
// This sets the __prerender_bypass cookie on the response.
// Every subsequent request from this browser will have draftMode().isEnabled === true
// until the cookie expires or draft.disable() is called.
const draft = await draftMode()
draft.enable()
// ── Redirect to the actual page ──────────────────────────────────────────
// `slug` arrives as a path like "/my-page" or "/".
// In this project all Payload pages are mounted under /works on the storefront.
// Adjust this prefix to match your routing.
redirect(`/works${slug}`)
}
The file path matters: src/app/(payload)/cms/api/draft/route.ts. The (payload) segment is a Next.js route group — it is invisible in the URL. The effective URL is /cms/api/draft. This must match the path used in generatePreviewUrl.ts.
The redirect sends a 307 Temporary Redirect response. The Set-Cookie header with the draft cookie is included in that same response. The browser stores the cookie, follows the redirect to /works/my-page, and sends the cookie along. The page's Server Components then see draftMode().isEnabled === true.
Step 7 — The Draft-Aware Data Fetcher
This is where most implementations break. The fetcher needs two completely separate code paths: a draft path that calls Payload directly with no caching, and a published path that serves from cache. Mixing these — or applying overrideAccess: true to published requests — creates both a performance problem and a security issue.
// src/payload/db/pages.ts
import { cacheTag } from 'next/cache'
import type { Page } from '@payload-types'
import { getPayloadClient } from './shared'
// ============================================================================
// RAW FETCHER — used for draft requests only
// No caching. overrideAccess: true so draft documents are readable.
// ============================================================================
async function fetchPageBySlugRaw(
slug: string,
draft: boolean,
locale: string,
): Promise<Page | null> {
const payload = await getPayloadClient()
try {
const { docs } = await payload.find({
collection: 'pages',
// overrideAccess bypasses your collection's `access.read` function.
// Required for drafts because draft documents are not publicly
// accessible — they would fail a normal access check.
// Only use this in server-side code that is already protected
// (e.g. behind draftMode() validation).
overrideAccess: true,
// draft: true tells Payload to return the latest DRAFT version
// of the document instead of the published version.
// Without this flag, payload.find() always returns published docs.
draft,
depth: 1, // resolve 1 level of relationships
limit: 1,
where: {
and: [
{ slug: { equals: slug } },
// In draft mode, show everything so editors can preview hidden pages.
...(draft ? [] : [{ hide: { equals: false } }]),
],
},
locale: locale as any,
})
return docs.length > 0 ? (docs[0] as Page) : null
} catch (error) {
console.error('[fetchPageBySlugRaw]', error)
return null
}
}
// ============================================================================
// CACHED WRAPPER — used for published requests only
// The "use cache: remote" directive stores the result in the data cache.
// cacheTag() allows targeted invalidation from the afterChange hook.
// ============================================================================
async function getCachedPageBySlug(
slug: string,
locale: string,
): Promise<Page | null> {
'use cache: remote'
cacheTag(`page:${slug}:${locale}`, 'pages')
// draft: false → Payload returns only published documents
return fetchPageBySlugRaw(slug, false, locale)
}
// ============================================================================
// PUBLIC API — the only function your pages should call
// ============================================================================
export async function queryPageBySlug({
slug,
draft = false,
locale = 'de',
}: {
slug: string
draft?: boolean
locale?: string
}): Promise<Page | null> {
if (draft) {
// Draft request: skip cache entirely, fetch live from Payload.
return fetchPageBySlugRaw(slug, true, locale)
}
// Published request: serve from cache.
return getCachedPageBySlug(slug, locale)
}
Two things in this fetcher are easy to get wrong and worth understanding clearly.
Why overrideAccess: true — Payload's access control for the pages collection might define read: () => true for published documents, but draft documents are always restricted. Payload treats them as internal regardless of your read function. When you call payload.find({ draft: true }) without overrideAccess: true, Payload checks whether the request carries a valid Payload auth token. Since your Server Component is not authenticated as a Payload user, the result set comes back empty. The overrideAccess: true flag tells Payload the call is coming from trusted server-side code and to skip the access check. This is safe here because the function is only reachable from the server, draft mode was already validated via the secret check in the Route Handler, and this code never runs in the browser.
Why draft: true in payload.find() — Payload stores multiple versions per document in a _pages_v (versions) table. Without draft: true, Payload queries the main pages table, which only contains the last published version. With draft: true, Payload queries the versions table and returns the most recent draft regardless of its published state.
Why overrideAccess: true only works with the local client — overrideAccess is only respected in local Payload API calls, when Next.js and Payload run in the same process. If you are calling a remote Payload REST API via fetch('/api/pages'), the parameter is ignored. Use getPayloadClient(), not the HTTP API, for draft fetching.
Step 8 — The RefreshRouteOnSave Component
This client component is the listener on the frontend side of the postMessage bridge. It registers a message event listener and calls your refresh callback whenever it receives a live preview event from Payload.
// src/app/[locale]/[...pathSegments]/refresh-route-on-save.tsx
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'
export const RefreshRouteOnSave = () => {
const router = useRouter()
return (
<PayloadLivePreview
// Called every time a postMessage arrives from the Payload parent frame.
// router.refresh() re-runs all Server Components on this page without
// a full browser navigation — the URL stays the same.
refresh={() => router.refresh()}
// serverURL scopes the postMessage event listener.
// Must match the origin of the Payload admin (window.parent.origin).
serverURL={process.env.NEXT_PUBLIC_SERVER_URL as string}
/>
)
}
router.refresh() tells Next.js to invalidate the server-side cache for the current route and re-fetch all Server Component data. It does not cause a full page navigation, does not lose React client state (scroll position, open modals, form state), and does not clear the draft mode cookie. The Server Components re-run on the server, the new HTML streams down, and React reconciles the DOM in place.
The serverURL prop is used to filter postMessage events by origin. If the origins don't match, the listener silently ignores all messages. To verify what origin the Payload admin is broadcasting from during development, open DevTools inside the iframe and run window.parent.origin in the console. That value must match what you're passing as serverURL.
What the library does internally:
// Simplified — what PayloadLivePreview does under the hood
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== serverURL) return
if (event.data?.type !== 'payload-live-preview') return
refresh()
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [serverURL, refresh])
Step 9 — Wire Up the Page
The page Server Component reads draftMode(), passes the result to the data fetcher, and conditionally mounts <RefreshRouteOnSave>. Only mount the component when isDraft is true — it adds a global event listener that you do not want on every public page load.
// src/app/[locale]/[...pathSegments]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { queryPageBySlug } from '@/payload/db/pages'
import { RefreshRouteOnSave } from './refresh-route-on-save'
import { RenderGeneralPageBlocks } from '@/components/payload/render-page-blocks'
import { PayloadPageLayout } from '@/components/payload/PayloadPageLayout'
import type { Locale } from '@/payload/db/cache-keys'
// generateStaticParams runs at build time to pre-render known pages.
// It only runs for published pages — draft mode is not active at build time.
export async function generateStaticParams(
{ params }: { params: { locale: string } }
) {
const slugs = await getAllPageSlugs(params.locale as Locale)
return slugs.map((slug) => ({ pathSegments: slug.split('/') }))
}
export default async function CatchAllPage(
props: { params: Promise<{ locale: string; pathSegments: string[] }> }
) {
return (
<Suspense fallback={null}>
<CatchAllRouteResolver {...props} />
</Suspense>
)
}
async function CatchAllRouteResolver(
props: { params: Promise<{ locale: string; pathSegments: string[] }> }
) {
const { locale, pathSegments } = await props.params
// Build the Payload slug from the URL segments.
// Strip the /works prefix since all Payload pages are mounted there.
const slug = pathSegments[0] === 'works'
? pathSegments.slice(1).join('/')
: pathSegments.join('/')
// draftMode() is async in Next.js 15+. Always await it.
// isEnabled is true only when the __prerender_bypass cookie is present.
const { isEnabled: isDraft } = await draftMode()
// When isDraft is true: calls fetchPageBySlugRaw() — no cache, overrideAccess: true, draft: true
// When isDraft is false: calls getCachedPageBySlug() — served from data cache, published only
const payloadPage = await queryPageBySlug({
slug,
locale: locale as Locale,
draft: isDraft,
})
if (!payloadPage) {
notFound()
}
return (
<>
{isDraft && <RefreshRouteOnSave />}
<PayloadPageLayout page={payloadPage} locale={locale as Locale} draft={isDraft}>
<RenderGeneralPageBlocks
blocks={payloadPage.layout}
locale={locale as Locale}
/>
</PayloadPageLayout>
</>
)
}
How Draft Mode Works in Next.js 15+
Worth covering in depth because this is what most implementations assume incorrectly.
draftMode() is async in Next.js 15+. The old synchronous call from Next.js 14 will cause a TypeScript error:
// Old (Next.js 14 and below) — do not use
const { isEnabled } = draftMode()
// Correct (Next.js 15+)
const { isEnabled } = await draftMode()
From a Server Component, you can only read the state. From a Route Handler (a route.ts file), you can also mutate it:
const draft = await draftMode()
draft.enable() // sets the draft cookie on the response
draft.disable() // clears the draft cookie
When draftMode().isEnabled is true, Next.js automatically skips all data cache reads for that request. You do not need to do anything special beyond checking isEnabled and routing to your uncached fetcher. The __prerender_bypass cookie is HTTP-only and signed, so JavaScript cannot read or forge it — only your server can enable or disable draft mode.
The full request lifecycle when a browser holds the draft cookie:
Browser (has __prerender_bypass cookie)
│
│ GET /works/my-page
│ Cookie: __prerender_bypass=abc123
▼
Next.js Server
│
├─ draftMode() → { isEnabled: true }
│
├─ All "use cache" segments are SKIPPED for this request
│
├─ generateStaticParams() output is IGNORED (no static path served)
│
└─ Page renders fully dynamically, fresh from origin
Verification Checklist
Work through these in order. Each step proves the previous one before you move on.
Check 1 — Draft route responds correctly
Visit with a wrong secret:
http://localhost:3000/cms/api/draft?slug=/test&secret=WRONG_SECRET
Expected: 401 Invalid token.
Visit with the correct secret:
http://localhost:3000/cms/api/draft?slug=/&secret=YOUR_ACTUAL_SECRET
Expected: Redirects to /works/ with a Set-Cookie header containing __prerender_bypass. Use your browser's Network tab to confirm the cookie is being set.
Check 2 — Draft mode is active on the redirected page
After following the redirect, open DevTools → Application → Cookies. You should see __prerender_bypass. Add a temporary debug line to your page Server Component:
const { isEnabled: isDraft } = await draftMode()
console.log('[CatchAllPage] isDraft:', isDraft) // should log true
Check 3 — Draft data is being fetched
Add a temporary debug line to fetchPageBySlugRaw:
console.log(`[fetchPageBySlugRaw] slug=${slug} draft=${draft} locale=${locale}`)
After visiting the draft URL, you should see the log with draft=true. Change a page field in Payload without publishing — the page should show the unpublished value on reload.
Check 4 — Live Preview panel opens
Log in to the Payload admin at http://localhost:3000/cms, open a Pages document, and click the Live Preview button in the top bar. The panel should split, showing your storefront in an iframe on the right. If the iframe is blank or shows an error, open DevTools inside the iframe (right-click → Inspect in Chrome) to see the actual error.
Check 5 — Changes reflect in real time
With the Live Preview panel open, change the page title. Wait approximately 1–2 seconds (the autosave interval). The iframe should re-render with the new title without a full page reload.
Troubleshooting Reference
iframe shows 401 Invalid token
The secret in the URL does not match process.env.PAYLOAD_SECRET in Next.js. Verify:
# In the Next.js process:
echo $PAYLOAD_SECRET
Check for trailing whitespace or newline characters in .env files — these are invisible and cause silent mismatches.
iframe loads the published page, not the draft
The draft cookie is not being set or not being sent.
Check that the draft route path matches CMS_BASE_PATH. If Payload is at /cms, the route must be at src/app/(payload)/cms/api/draft/route.ts, making the URL /cms/api/draft. Any mismatch means the Route Handler is never reached and the cookie is never set.
Verify the redirect path is correct. The draft route redirects to /works${slug}. If your page is not under /works, the cookie-carrying redirect goes to a page that does not perform the draft-aware fetch.
Check for https vs http mismatch. Secure cookies are not sent over plain HTTP except on localhost.
Changes don't appear in the iframe after autosave
First, verify isDraft is actually true when the preview page loads. Add the console.log from Check 2 above.
Second, check the serverURL in <RefreshRouteOnSave>. The value must match the origin of the Payload admin window (the parent frame). Open DevTools in the iframe, go to the Console tab, and run:
window.parent.origin
That result must match what you are passing as serverURL.
Third, confirm autosave is triggering. Autosave only fires when there are unsaved changes. Make any field change to trigger the first autosave.
Live Preview button doesn't appear in the Payload admin
versions.drafts is not configured on the collection. Payload hides the Live Preview button if the collection does not support drafts. Add the versions config from Step 5.
Getting empty results when fetching drafts
You are likely calling the remote Payload REST API (fetch('/api/pages')) rather than the local client. overrideAccess is only respected in local API calls when Next.js and Payload run in the same process. Switch to getPayloadClient().
FAQ
Do I need to call draftMode().disable() anywhere?
Only if you want to build an explicit "exit preview" route. For most setups, draft mode expires naturally when the session ends or the browser is closed. If you want a button that exits preview mode, create a second Route Handler that calls draft.disable() and redirects back to the published page.
Can I use live preview with Payload hosted as a separate service?
The overrideAccess: true pattern requires the local Payload client, which means Next.js and Payload must run in the same process. If you are running Payload separately and calling it via REST, you will need to use a Payload API token in the fetch headers instead — overrideAccess via HTTP is not supported.
What happens to generateStaticParams when draft mode is active?
It is ignored. Static paths are only served when the draft cookie is absent. A request with the __prerender_bypass cookie is always rendered dynamically, even if the slug exists in generateStaticParams output.
Why does the iframe show a blank page on first load?
The most common cause is a slug mismatch. The draft route builds the redirect as /works${slug}, where slug comes from the query parameter. If that resolves to a path that does not exist on your storefront, the page component calls notFound() and the iframe shows a 404. Log the slug value in the Route Handler to confirm it resolves as expected.
Can I use live preview with collections that don't have a slug field?
The preview URL generator needs something to build a path from. If your collection uses a different identifier (ID, title, custom field), modify buildPreviewUrl to accept that field and construct the path accordingly. The rest of the chain — draft route, cookie, data fetcher — is unchanged.
Complete File Map
src/
├── payload/
│ ├── config/
│ │ └── routes.ts CMS_BASE_PATH = "/cms"
│ ├── utilities/
│ │ └── generatePreviewUrl.ts Builds /cms/api/draft?slug=...&secret=...
│ └── collections/
│ └── content/
│ └── pages/
│ └── index.ts admin.preview + admin.livePreview + versions.drafts
│
├── payload/db/
│ └── pages.ts queryPageBySlug() — draft-aware, cache-bypassing
│
└── app/
├── (payload)/
│ └── cms/
│ └── api/
│ └── draft/
│ └── route.ts Validates secret, enables draftMode(), redirects
└── [locale]/
└── [...pathSegments]/
├── page.tsx Reads draftMode(), passes draft flag to fetcher
└── refresh-route-on-save.tsx 'use client' — listens for postMessage, calls router.refresh()
Summary
The entire system is five connections between three independent mechanisms:
| Wire | From | To |
|---|---|---|
| 1 | Payload livePreview.url | Draft route URL |
| 2 | Draft route draftMode().enable() | Next.js draft cookie |
| 3 | Page draftMode().isEnabled | draft: true in queryPageBySlug |
| 4 | queryPageBySlug({ draft: true }) | fetchPageBySlugRaw (no cache) |
| 5 | postMessage from Payload | router.refresh() via RefreshRouteOnSave |
Each wire is simple on its own. A missing cookie, a wrong path, a cached fetch, or a mismatched serverURL silently kills the whole chain — which is why the verification checklist matters. Work through it step by step and each connection becomes observable before you depend on the next one.
If you hit issues or found a different setup that works better for your project, let me know in the comments. And if you are working with Payload CMS and Next.js, subscribe for more practical implementation guides.
Thanks, Matija
📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
Frequently Asked Questions
Comments
No comments yet
Be the first to share your thoughts on this post!