---
title: "Complete Guide to Payload CMS Live Preview with Next.js"
slug: "live-preview-payload-cms-nextjs-implementation-guide"
published: "2026-05-01"
updated: "2026-05-02"
validated: "2026-05-02"
categories:
  - "Payload"
tags:
  - "Payload CMS live preview"
  - "Next.js Draft Mode"
  - "postMessage live preview"
  - "Payload versions drafts"
  - "generatePreviewUrl"
  - "queryPageBySlug"
  - "overrideAccess"
  - "RefreshRouteOnSave"
  - "draft route handler"
  - "autosave interval"
  - "Payload CMS Next.js integration"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "next.js"
  - "payload cms"
  - "typescript"
  - "react"
  - "@payloadcms/live-preview-react"
status: "stable"
llm-purpose: "Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…"
llm-prereqs:
  - "Access to Next.js"
  - "Access to Payload CMS"
  - "Access to TypeScript"
  - "Access to React"
  - "Access to @payloadcms/live-preview-react"
llm-outputs:
  - "Completed outcome: Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…"
---

**Summary Triples**
- (Next.js Draft Mode, uses-signed-http-only-cookie, __prerender_bypass (enables draft session for Server Components))
- (Payload versions.drafts, creates, a new version record on each save (instead of overwriting published document))
- (autosave, generates, near-real-time draft versions while the editor is typing (interval-configurable))
- (postMessage bridge, notifies, the preview iframe to reload when a new draft/version is saved)
- (generatePreviewUrl, must-return, a preview URL that the preview API route redirects to after enabling Draft Mode)
- (queryPageBySlug / server query, should-pass, overrideAccess (or equivalent) to fetch draft versions instead of published content)
- (cookie path/domain mismatch, causes, silent failures where Draft Mode cookie is set but not sent to preview route -> preview shows published content)
- (order-of-operations, is-critical, enable Draft Mode (set cookie) -> redirect to preview URL -> render server components that query drafts)

### {GOAL}
Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…

### {PREREQS}
- Access to Next.js
- Access to Payload CMS
- Access to TypeScript
- Access to React
- Access to @payloadcms/live-preview-react

### {STEPS}
1. Understand the three core mechanisms
2. Install live-preview helper package
3. Add required environment variables
4. Configure Payload and base path
5. Build the preview URL generator
6. Enable collection drafts and preview settings
7. Create the draft route handler
8. Implement a draft-aware data fetcher
9. Mount RefreshRouteOnSave on draft pages

<!-- llm:goal="Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to @payloadcms/live-preview-react" -->
<!-- llm:output="Completed outcome: Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…" -->

# Complete Guide to Payload CMS Live Preview with Next.js
> Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…
Matija Žiberna · 2026-05-01

# Live 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 `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.ts
import { buildConfig } from 'payload'

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET || '',
  // ... rest of your config
})
```

```ts
// 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.

```ts
// 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.

```ts
// 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.

```ts
// 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.

```ts
// 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.

```tsx
// 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:

```ts
// 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.

```tsx
// 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:

```ts
// 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:

```ts
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:

```ts
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`:

```ts
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:

```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.

---

## 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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…",
  "responses": [
    {
      "question": "What does the article \"Complete Guide to Payload CMS Live Preview with Next.js\" cover?",
      "answer": "Payload CMS live preview: enable Next.js Draft Mode, Payload drafts, and a postMessage bridge for real-time preview; step-by-step, checks, and…"
    }
  ]
}
```