The One-Route Payload CMS Live Preview Pattern
Wire draft preview in Payload CMS + Next.js App Router with one route, one secret, and a hard rule: preview stays uncached even when the whole public site is cached.

When you wire up a payload cms live preview, you’re not just plumbing a URL—you’re building a contract between the admin panel and your Next.js App Router. The goal is keystrokes-ago fidelity: editors click Preview, land on your front end, and see exactly what’s in the draft, even when the public site is cached to the hilt. Our implementation at techpotions settled on one preview route, one shared secret, and one shared URL builder. Here’s every decision that made it work.
One /next/preview route for the entire site
The admin panel’s preview button doesn’t need to know about your page structure. It calls a single /next/preview route with a secret query param and a slug search param that points to the document being previewed.
// app/(payload)/next/preview/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')
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
const draft = await draftMode()
draft.enable()
redirect(slug ?? '/')
}That’s the entire route. No collection-specific logic, no second-guessing which page type is involved. The redirect lands on the actual page, which reads draftMode().isEnabled and fetches accordingly. This is the pattern the Payload CMS preview documentation expects: a function that resolves to a string with additional URL parameters pointing to your app.
The preview-URL builder lives in one shared lib
Here’s where most implementations drift apart. The admin config, the preview route, and each page component all need to agree on how a preview URL is constructed. Store that logic in one place—a single getPreviewUrl utility imported everywhere—or you’ll be chasing 404s in production when someone renames a collection slug.
// lib/getPreviewUrl.ts
export function getPreviewUrl(collection: string, slug: string): string {
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
const params = new URLSearchParams({
secret: process.env.PREVIEW_SECRET!,
slug: `/${collection}/${slug}`,
})
return `${base}/next/preview?${params.toString()}`
}Use it in your Payload collection config:
// collections/Posts.ts
import { getPreviewUrl } from '@/lib/getPreviewUrl'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
preview: (doc) => getPreviewUrl('posts', doc?.slug as string),
},
// ...
}And in your page component’s data-fetching logic—every page that can be previewed imports the same function so the path structure can’t accidentally fork. When a content team creates a new collection six months from now, adding preview is a one-line hook into the shared builder.
The page fetches with draft: true—and stays uncached
The page component checks Next.js draft mode, then tells Payload’s local API to return the latest draft version. For our web development projects, we pair draftMode() with fetch options that explicitly disable caching, because the public site may be wrapped in aggressive CDN or ISR layers.
// app/(frontend)/posts/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { getPayload } from '@/lib/payload'
interface Props {
params: Promise<{ slug: string }>
}
export default async function PostPage({ params }: Props) {
const { isEnabled: isDraft } = await draftMode()
const { slug } = await params
const payload = await getPayload()
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
draft: isDraft,
depth: 2,
})
const post = docs[0]
if (!post) notFound()
return <article>{/* render post */}</article>
}This approach also lets editors preview scheduled posts before their publish time—the draft: true flag surfaces documents with a future publishDate that the public query would hide. It’s a must-have for time-sensitive editorial workflows.
Two secrets, two jobs: PREVIEW_SECRET ≠ CRON_SECRET
A side-channel like draft mode deserves its own credential. PREVIEW_SECRET authorizes the preview route and appears in the URL builder. It is deliberately separate from the CRON_SECRET that drains the scheduled-publish queue, so rotating or leaking one doesn’t compromise the other. Both live in environment variables; neither is hardcoded.
Variable | Scope | Where it’s used |
|---|---|---|
| Draft preview entry point | |
| Scheduled publish drain | |
This separation isn’t paranoia—it’s hygiene. The preview secret appears in URLs that editors share around a newsroom; the cron secret touches Vercel Cron Jobs or an external scheduler. If the preview secret winds up in a screenshot or log, you rotate it without touching your publish infrastructure.
Live Preview vs. Draft Preview: when to use which
The payload cms live preview feature (distinct from draft preview) uses window.postMessage to push real-time field changes from the admin panel into an iframe without a page reload. For deeply interactive, layout-heavy pages—landing pages with hero blocks, calls-to-action, and rich media—live preview is transformative. But it requires SSR and a stable iframe target.
Draft preview (the /next/preview pattern described here) works for nearly every collection type, leaves no iframe-shaped holes in your routing, and is the right default. Start with draft preview; layer in Payload’s Live Preview for high-interaction documents where seeing the rendered output with every keystroke directly drives editorial speed.
Wiring the admin’s preview button
In your Payload config, set admin.livePreview.url to your front-end’s root so the iframe has a base to load, then configure the preview function per collection:
// payload.config.ts
export default buildConfig({
admin: {
livePreview: {
url: process.env.NEXT_PUBLIC_SITE_URL,
},
},
collections: [Posts],
// ...
})When an editor clicks the Preview button in the sidebar, Payload calls the collection’s admin.preview function and opens the resulting URL in a new tab (for draft preview) or an iframe (for live preview). The /next/preview route handles the rest.
FAQ
Why does my preview show stale content even when draft mode is enabled?
Nearly always caching. The public site likely runs with ISR or CDN cache headers. Make the preview page explicitly opt out with export const dynamic = 'force-dynamic' or by setting Cache-Control: no-store in your fetch calls. Draft mode must see keystrokes-ago state; any caching layer between the editor and the render defeats the purpose.
Do I need a separate preview route for every collection?
No—and you shouldn’t build one. A single app/(payload)/next/preview/route.ts validates the shared secret, enables draft mode, and redirects to whatever slug was passed. Let the page component decide how to fetch its data based on draftMode().isEnabled. The shared getPreviewUrl ensures the admin config and the route never disagree on path structure.
How do I show scheduled (future-publish) content in preview?
Enable draft mode, then query your Payload collection with draft: true. Documents with a publishDate in the future that are excluded from public queries will appear in draft-mode responses. This is the same mechanism that surfaces saved-but-unpublished drafts, and it requires versions: { drafts: true } on the collection.
--- Need a preview implementation that survives your next redesign? Our team at techpotions builds Payload + Next.js authoring experiences where editors see exactly what ships. Start a project.