WABBIT · ESSAYS

Draft preview and live preview in Payload CMS (Payload 3 + Next.js)

Preview in Payload 3 is six small pieces across five files. Here's each one, in the order we wire them, plus the auth check that a common example gets wrong.

  • Payload 3.90 · Next.js
  • 6 steps
  • 8 min read
Built on
Payload 3.90, Next.js 16 App Router
Starts from
Payload's website template, or any Payload 3 + Next.js app
Steps
6, about 8 min read
Files you'll touch
6 files, listed per step
  • src/collections/Pages/index.ts
  • src/utilities/generatePreviewPath.ts
  • src/app/(frontend)/next/preview/route.ts
  • src/app/(frontend)/[slug]/page.tsx
  • src/components/LivePreviewListener.tsx
  • src/middleware.ts

Payload has two preview buttons, and they're easy to mix up. Preview opens your front end in a new tab with draft mode switched on. Live Preview loads the same front end in an iframe beside the editor and refreshes it as the editor types. Both rely on the same plumbing: drafts in the collection, a route that turns on Next.js draft mode, and a page query that reads drafts when that mode is on. Live Preview adds one small client component on top.

When preview breaks, it rarely throws. The pane stays on the published version, or it shows a blank frame, or it works for you and for anyone else who has the link. Each of those traces back to one of the six steps below. The code comes from wabbit.com, which runs on Payload 3.90 and Next.js 16, trimmed down for a generic project. If you started from Payload's website template, most of it is already in your repo, and this is how to check it.

Step 1: Turn on drafts, and keep them private

Preview needs something to preview. Drafts give each document a _status of draft or published and keep unpublished changes in a versions table, so the live page doesn't change until someone presses Publish.

src/collections/Pages/index.tsTypeScript · excerpt

Turns on drafts with autosave for the collection, and keeps at most 50 stored versions per document.

import type { CollectionConfig } from 'payload'
import { authenticatedOrPublished } from
  '../../access/authenticatedOrPublished'

export const Pages: CollectionConfig = {
  slug: 'pages',
  access: {
    read: authenticatedOrPublished,
    // create, update, delete: your own rules
  },
  versions: {
    drafts: {
      autosave: { interval: 1000 },
    },
    maxPerDoc: 50,
  },
  fields: [
    // title, slug, layout...
  ],
}

The read rule matters as much as the drafts switch. Payload's docs are explicit that draft: true on a query doesn't hide draft documents by itself; access control does. The rule we use returns true for a logged-in user and a query constraint for everyone else:

src/access/authenticatedOrPublished.tsTypeScript

Lets a logged-in user read everything, and the public read only published documents.

import type { Access } from 'payload'

export const authenticatedOrPublished: Access = ({
  req: { user },
}) => {
  if (user) return true
  return { _status: { equals: 'published' } }
}

Autosave is optional for the Preview button and close to required for Live Preview. Without it, the iframe only refreshes when the editor saves a draft by hand. Our interval is one second; Payload's server-side live preview docs use 375 ms in their example. Lower is snappier and writes more versions, which is why we cap them with maxPerDoc.

If you add drafts to a collection that already has documents, those documents have no _status until they're saved again. The docs suggest letting the public rule also match documents where _status doesn't exist, until you've resaved them.

Step 2: Build one preview URL for both buttons

Both buttons take a function that returns a URL. Point them at the same helper, so the two can't drift. The helper doesn't link straight to the page; it links to a route on your front end that switches draft mode on first and then redirects.

src/utilities/generatePreviewPath.tsTypeScript

Builds the one preview URL both buttons use. It points at the preview route, with the slug, the path and the secret.

import type { CollectionSlug } from 'payload'

type Prefixes = Partial<
  Record<CollectionSlug, string>
>

const prefix: Prefixes = {
  pages: '',
  posts: '/posts',
}

export const generatePreviewPath = ({
  collection,
  slug,
}: {
  collection: keyof typeof prefix
  slug: string
}) => {
  const path =
    collection === 'pages' && slug === 'home'
      ? '/'
      : `${prefix[collection]}/${slug}`

  const params = new URLSearchParams({
    slug,
    collection,
    path,
    previewSecret: process.env.PREVIEW_SECRET || '',
  })

  return `/next/preview?${params.toString()}`
}

Then wire both buttons in the collection's admin block:

src/collections/Pages/index.tsTypeScript · excerpt

Wires the Preview and Live Preview buttons to the same URL helper.

admin: {
  livePreview: {
    url: ({ data }) =>
      generatePreviewPath({
        collection: 'pages',
        slug: String(data?.slug ?? ''),
      }),
  },
  preview: (data) =>
    generatePreviewPath({
      collection: 'pages',
      slug: String(data?.slug ?? ''),
    }),
},

The URL is relative, which works when the admin and the front end are one Next.js app (the default for Payload 3). The path must be the same URL your router serves. On wabbit.com, pages can sit under a section such as /compare/…, so the path comes from the same pagePath() function our sitemaps and links use. If preview builds paths one way and the router another, preview lands on a 404.

PREVIEW_SECRET goes in your .env. It travels in the query string, so treat it as a speed bump against drive-by requests and not as the real lock. The real lock is the user check in the next step.

Step 3: Write the preview route

The route checks the secret, checks that the request comes from a logged-in Payload user, turns on draft mode and redirects to the page. Draft mode is a cookie, so every request after the redirect renders with drafts.

src/app/(frontend)/next/preview/route.tsTypeScript

Checks the secret and the logged-in user, turns on draft mode, then redirects to the page.

import { getPayload } from 'payload'
import config from '@payload-config'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import type { NextRequest } from 'next/server'

const deny = (msg: string, status = 403) =>
  new Response(msg, { status })

export async function GET(req: NextRequest) {
  const payload = await getPayload({ config })
  const params = req.nextUrl.searchParams
  const path = params.get('path')
  const secret = params.get('previewSecret')

  if (secret !== process.env.PREVIEW_SECRET) {
    return deny('Not allowed')
  }
  if (!path || !path.startsWith('/')) {
    return deny('Bad path', 400)
  }

  // auth() returns { user, permissions, ... }
  const { user } = await payload.auth({
    headers: req.headers,
  })

  const draft = await draftMode()
  if (!user) {
    draft.disable()
    return deny('Not allowed')
  }

  draft.enable()
  redirect(path)
}

Look at the payload.auth() line. It returns an object with user, permissions and sometimes responseHeaders, and the user is null when nobody is logged in. A common example of this route assigns that whole object to user, so the if (!user) check after it never fires: the object is always truthy. Copy that version and the only thing between the public and your drafts is the secret in the URL. Payload's website template on GitHub reads authResult.user, which is right. Destructure user as above and it works.

If your users collection holds more than editors (customers, members, students), check the role here too. Any logged-in user passes if (!user), and the page query in step 4 bypasses access control in draft mode. One of our Payload sites adds a role check before draft.enable() and returns 403 for anyone who isn't an editor or admin.

You also need a way out. Payload's admin bar calls an exit route when the editor clicks "Exit preview":

src/app/(frontend)/next/exit-preview/route.tsTypeScript

Turns draft mode off. The admin bar's Exit preview calls it.

import { draftMode } from 'next/headers'

export async function GET() {
  const draft = await draftMode()
  draft.disable()
  return new Response('Draft mode is disabled')
}

Step 4: Query drafts when draft mode is on

The page reads draft mode and passes it to the query. draft: true asks Payload for the newest version, published or not. overrideAccess: draft skips the public read rule, which would otherwise filter the draft back out.

src/app/(frontend)/[slug]/page.tsxTSX · excerpt

Reads drafts when draft mode is on, and skips the public read rule so the draft isn't filtered out.

import { cache } from 'react'
import { draftMode } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'

const queryPageBySlug = cache(
  async ({ slug }: { slug: string }) => {
    const { isEnabled: draft } = await draftMode()
    const payload = await getPayload({ config })

    const result = await payload.find({
      collection: 'pages',
      draft,
      overrideAccess: draft,
      limit: 1,
      pagination: false,
      sort: '-createdAt',
      where: { slug: { equals: slug } },
    })

    return result.docs?.[0] ?? null
  },
)

React's cache lets the page and generateMetadata share one query per request. The sort is a guard we added after finding two published pages with the same slug: nothing in a stock Pages collection enforces unique slugs, and without a sort, which one serves the URL comes down to database order. Sorting newest first makes the choice deterministic. The real fix is unpublishing the duplicate.

generateStaticParams should keep asking for published pages only, with draft: false and overrideAccess: false, so drafts never get prerendered.

Watch for data helpers that hard-code published content. Next.js skips the unstable_cache store in draft mode, so a cached helper won't serve you a stale entry during preview. But if the helper's own query says _status: { equals: 'published' }, it still returns the published page, and preview looks broken when it's the query that's wrong. Anything the page reads during preview has to take the draft flag.

Step 5: Refresh the page when the editor saves

For Live Preview on the App Router, Payload's docs recommend the server-side approach: a client component that listens for save events from the admin and calls router.refresh(). Next.js re-runs the server components, the draft query from step 4 runs again, and the iframe shows the new content.

src/components/LivePreviewListener.tsxTSX

Refreshes the route when the admin saves. It ignores any message that isn't from the admin's exact origin.

'use client'
import { RefreshRouteOnSave } from
  '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'

// The admin's origin. In the browser, read it;
// during server render, fall back to the env var.
const adminOrigin = () =>
  typeof window !== 'undefined'
    ? window.location.origin
    : process.env.NEXT_PUBLIC_SERVER_URL ?? ''

export function LivePreviewListener() {
  const router = useRouter()
  return (
    <RefreshRouteOnSave
      refresh={router.refresh}
      serverURL={adminOrigin()}
    />
  )
}

Mount it only in draft mode, anywhere in the page:

src/app/(frontend)/[slug]/page.tsxTSX · excerpt

Mounts the listener only in draft mode.

{draft && <LivePreviewListener />}

serverURL has to be the admin's origin exactly. The component compares it to event.origin with a strict equality check and ignores every message that doesn't match. There's no error and no warning. A trailing slash, http against https, or localhost against 127.0.0.1 all fail the same quiet way. When the admin and the front end are one app, window.location.origin is right by construction, and it's what our getClientSideURL() helper returns in the browser. The typeof window guard is there because Next.js renders client components on the server too, where window doesn't exist. The value only matters in the browser, where the component listens for messages. Payload's own docs example reads an env var with the NEXT_PUBLIC_ prefix instead, which also works as long as it matches the admin's origin exactly. If the admin lives on another domain, pass that domain.

The other live preview package API, useLivePreview, merges form state into data on the client and updates on every keystroke. It suits client-rendered pages. For server components the docs point to RefreshRouteOnSave, which is what the website template uses.

Step 6: Let the admin frame your site

Live Preview is an iframe, so your site has to allow being framed by the admin. A common security header, X-Frame-Options: DENY, blocks all framing, including your own admin. Ours sets this in middleware:

src/middleware.tsTypeScript · excerpt

Lets the admin frame the site, while other sites still cannot.

response.headers.set(
  'X-Frame-Options',
  'SAMEORIGIN',
)
response.headers.set(
  'Content-Security-Policy',
  "frame-ancestors 'self'",
)

SAMEORIGIN and frame-ancestors 'self' still block other sites from framing yours, which is what the header is for. If the admin runs on a different origin, list it in frame-ancestors. Payload's docs give the same advice for an iframe that "refuses to connect".

Common mistakes

Trusting the whole payload.auth() result. Covered in step 3, and worth checking even if preview works. A route that assigns the result object to user lets anyone with the preview URL turn on draft mode, and from then on the page query reads drafts with access control off. Test it: open the preview URL in a private window. You should get a 403.

A serverURL that's almost right. The pane loads, shows your draft once (the component refreshes when it mounts), then never updates. The origin check in step 5 is dropping every save event. Log window.location.origin inside the iframe and compare it character by character with what you pass.

Expecting keystroke updates from RefreshRouteOnSave. It refreshes on save, not on every keystroke. With autosave on, a save happens on the interval you set; without autosave, nothing happens until the editor saves. If the pane feels slow, lower the interval before reaching for anything else.

A preview path the router doesn't serve. Preview redirects to whatever path the helper built. If your routes are nested (posts under /blog, pages under sections), and the helper builds /<slug>, the redirect lands on a 404 or on a different page with the same slug. Build preview paths with the same function your links and sitemap use.

Draft mode that sticks. The draft mode cookie lasts until the browser closes, not until the tab does. An editor who previews a page and then browses the live site in the same browser sees drafts everywhere, and can report a "bug" that's really an unpublished change. Keep the admin bar's "Exit preview" wired to the exit route, and keep draft-mode visits out of your analytics.

How Tome does it

Tome sites start from this same wiring, and the live preview listener ships with the starter, so a new block appears in the pane as soon as it's in the render map, as in the custom block tutorial. On wabbit.com we also build the preview path with the same pagePath() function that feeds our sitemaps, canonicals and revalidation, so a nested URL previews where it publishes (the Payload SEO tutorial covers that function). After publishing, the page still has to drop its cached copy; that's the next tutorial.

FAQ

What's the difference between Preview and Live Preview in Payload?

Preview (admin.preview) opens your front end in a new tab, usually through a route that turns on draft mode. Live Preview (admin.livePreview) loads your front end in an iframe inside the admin and refreshes it as the document changes. Both can point at the same preview route.

Why does Payload live preview show the published version instead of my draft?

Usually the page query isn't reading drafts. Pass draft: true (or the draft mode flag) to payload.find, and overrideAccess: true when draft mode is on, so the public read rule doesn't filter the draft out. Also check any cached helper the page uses: if its query filters on _status: 'published', it ignores draft mode.

Why doesn't Payload live preview update when I type?

RefreshRouteOnSave refreshes the route on save events, so turn on autosave in versions.drafts. If it still doesn't update, the serverURL you pass probably doesn't match the admin's origin exactly; the component silently ignores messages from any other origin.

Is PREVIEW_SECRET enough to protect drafts?

No. The secret sits in a URL, so it ends up in browser history and logs. The preview route must also confirm a logged-in user with payload.auth(), reading the user property from the result, and ideally check the user's role.

Do I need Next.js draft mode for Payload live preview?

For the server-side approach, yes. Draft mode is what tells your server components to query drafts. The preview route turns it on with (await draftMode()).enable() before redirecting to the page.