Last updated: April 2026 · 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:
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 slug field
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
bash
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.
bash
# .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.
ts
// payload.config.tsimport { buildConfig } from'payload'exportdefaultbuildConfig({
secret: process.env.PAYLOAD_SECRET || '',
// ... rest of your config
})
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.
ts
// src/payload/utilities/generatePreviewUrl.tsimport { CMS_BASE_PATH } from'@/payload/config/routes'interfaceGeneratePreviewUrlOptions {
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 varconst localeUrl = process.env[`NEXT_PUBLIC_SERVER_URL_${effectiveLocale.toUpperCase()}`]
if (localeUrl) return localeUrl
// Dev fallback: swap subdomain de.example.com → en.example.comtry {
const url = newURL(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) {
thrownewError('PAYLOAD_SECRET is not set. Live preview will not work.')
}
// Derive the storefront path from the slugletpath: stringif (slug === 'home') {
path = '/'
} elseif (basePath) {
path = `/${basePath}/${slug}`
} else {
path = `/${slug}`
}
const params = newURLSearchParams({
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.
*/exportconst generatePagePreviewUrl = async (
doc: any,
options?: { locale?: string },
): Promise<string | null> => {
if (!doc?.slug) returnnullreturnbuildPreviewUrl(doc.slug, undefined, options?.locale)
}
/**
* Factory for collections that live under a URL prefix.
* e.g. createPreviewUrlGenerator('careers') → /careers/my-job-slug
*/exportconstcreatePreviewUrlGenerator = (basePath: string) => {
returnasync (
doc: any,
options?: { locale?: string },
): Promise<string | null> => {
if (!doc?.slug) returnnullreturnbuildPreviewUrl(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.
ts
// src/payload/collections/content/pages/index.tsimporttype { CollectionConfig } from'payload'import { generatePagePreviewUrl } from'@/payload/utilities/generatePreviewUrl'exportconstPages: 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.
ts
// src/app/(payload)/cms/api/draft/route.tsimport { draftMode } from'next/headers'import { redirect } from'next/navigation'exportasyncfunctionGET(request: Request) {
const { searchParams } = newURL(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) {
returnnewResponse('Invalid token', { status: 401 })
}
// ── Parameter validation ─────────────────────────────────────────────────if (!slug) {
returnnewResponse('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 = awaitdraftMode()
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.
ts
// src/payload/db/pages.tsimport { cacheTag } from'next/cache'importtype { Page } from'@payload-types'import { getPayloadClient } from'./shared'// ============================================================================// RAW FETCHER — used for draft requests only// No caching. overrideAccess: true so draft documents are readable.// ============================================================================asyncfunctionfetchPageBySlugRaw(slug: string,
draft: boolean,
locale: string,
): Promise<Page | null> {
const payload = awaitgetPayloadClient()
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 relationshipslimit: 1,
where: {
and: [
{ slug: { equals: slug } },
// In draft mode, show everything so editors can preview hidden pages.
...(draft ? [] : [{ hide: { equals: false } }]),
],
},
locale: locale asany,
})
return docs.length > 0 ? (docs[0] asPage) : null
} catch (error) {
console.error('[fetchPageBySlugRaw]', error)
returnnull
}
}
// ============================================================================// 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.// ============================================================================asyncfunctiongetCachedPageBySlug(slug: string,
locale: string,
): Promise<Page | null> {
'use cache: remote'cacheTag(`page:${slug}:${locale}`, 'pages')
// draft: false → Payload returns only published documentsreturnfetchPageBySlugRaw(slug, false, locale)
}
// ============================================================================// PUBLIC API — the only function your pages should call// ============================================================================exportasyncfunctionqueryPageBySlug({
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.returnfetchPageBySlugRaw(slug, true, locale)
}
// Published request: serve from cache.returngetCachedPageBySlug(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.
tsx
// src/app/[locale]/[...pathSegments]/refresh-route-on-save.tsx'use client'import { RefreshRouteOnSaveasPayloadLivePreview } from'@payloadcms/live-preview-react'import { useRouter } from'next/navigation'exportconstRefreshRouteOnSave = () => {
const router = useRouter()
return (
<PayloadLivePreview
// CalledeverytimeapostMessagearrivesfromthePayloadparentframe.
// router.refresh() re-runsallServerComponentsonthispagewithout
// afullbrowsernavigation — theURLstaysthesame.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:
ts
// Simplified — what PayloadLivePreview does under the hooduseEffect(() => {
consthandler = (event: MessageEvent) => {
if (event.origin !== serverURL) returnif (event.data?.type !== 'payload-live-preview') returnrefresh()
}
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.
tsx
// src/app/[locale]/[...pathSegments]/page.tsximport { 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'importtype { 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.exportasyncfunctiongenerateStaticParams(
{ params }: { params: { locale: string } }
) {
const slugs = awaitgetAllPageSlugs(params.localeasLocale)
return slugs.map((slug) => ({ pathSegments: slug.split('/') }))
}
exportdefaultasyncfunctionCatchAllPage(props: { params: Promise<{ locale: string; pathSegments: string[] }> }
) {
return (
<Suspensefallback={null}><CatchAllRouteResolver {...props} /></Suspense>
)
}
asyncfunctionCatchAllRouteResolver(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 } = awaitdraftMode()
// When isDraft is true: calls fetchPageBySlugRaw() — no cache, overrideAccess: true, draft: true// When isDraft is false: calls getCachedPageBySlug() — served from data cache, published onlyconst payloadPage = awaitqueryPageBySlug({
slug,
locale: locale asLocale,
draft: isDraft,
})
if (!payloadPage) {
notFound()
}
return (
<>
{isDraft && <RefreshRouteOnSave />}
<PayloadPageLayoutpage={payloadPage}locale={localeasLocale} draft={isDraft}><RenderGeneralPageBlocksblocks={payloadPage.layout}locale={localeasLocale}
/></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:
ts
// Old (Next.js 14 and below) — do not useconst { isEnabled } = draftMode()
// Correct (Next.js 15+)const { isEnabled } = awaitdraftMode()
From a Server Component, you can only read the state. From a Route Handler (a route.ts file), you can also mutate it:
ts
const draft = awaitdraftMode()
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:
code
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.
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:
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:
bash
# 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:
js
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.
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.