- Next.js Draft Mode + ISR: Single-Route Live Previews
Next.js Draft Mode + ISR: Single-Route Live Previews
Enable ISR and static generation while providing instant Payload CMS previews on a single Next.js route—no duplicate…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Related Posts:
I was building a multi-tenant Next.js site with Payload CMS when I hit a classic problem: editors needed instant preview updates, but production needed static performance. After trying a few patterns, I settled on one approach that gave us both without duplicating route trees. This guide walks you through the exact implementation so you can run ISR and Draft Mode on the same route.
The Problem Setup
When you run a content-heavy site, production speed depends on static generation and caching. At the same time, editors expect a live preview workflow where saving content immediately updates what they see. Most teams choose one of two paths: make pages fully dynamic and lose static performance, or build separate preview routes and maintain duplicate logic.
In this implementation, the goal is narrower and more practical: keep one route, keep static generation, and still provide live draft preview behavior for editors.
Step 1: Build a Single Route Entry for Tenant Pages
The first decision is route architecture. In this project, src/app/(frontend)/tenant-slugs/[tenant]/page.tsx is a wrapper that delegates everything to the catch-all route and shares static params generation.
// File: src/app/(frontend)/tenant-slugs/[tenant]/page.tsx
import Page from './[...slug]/page'
import { getStaticPaths } from '@/payload/db'
export async function generateStaticParams() {
return await getStaticPaths()
}
export default Page
The actual route logic lives in src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx, where static and dynamic behavior are configured together.
// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx
export const dynamicParams = true
export const revalidate = 604800 // 7 days
export async function generateStaticParams() {
return await getStaticPaths()
}
This gives you a strong base: known routes are pre-rendered, unknown routes can still render on demand, and every request goes through one code path. That one-path design is what makes the rest of this pattern clean.
With route structure in place, the next step is enabling Draft Mode safely.
Step 2: Enable Draft Mode Through a Secure API Entry Point
Draft Mode is turned on through src/app/api/draft/route.ts. Payload preview URLs call this endpoint with a secret and slug.
// File: src/app/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')
if (secret !== process.env.PAYLOAD_SECRET && secret !== 'demo-secret') {
return new Response('Invalid token', { status: 401 })
}
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
const draft = await draftMode()
draft.enable()
redirect(slug)
}
Preview URLs are produced from Payload config helpers and routed to this endpoint.
// File: src/payload/utilities/generatePreviewUrl.ts
const params = new URLSearchParams({
slug: path,
secret: process.env.PAYLOAD_SECRET as string,
})
return `${protocol}://${finalDomain}/api/draft?${params.toString()}`
This works because the endpoint sets the draft cookie and sends the editor back to the normal frontend path, not a separate preview path. From here, the same route can decide whether to render draft or published data.
Step 3: Branch Behavior by Draft Mode Inside the Same Page Route
In the catch-all route, the key switch is draftMode().isEnabled. That value is read in both generateMetadata and the page component, so metadata and content stay in sync.
// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx
import { draftMode } from 'next/headers'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, tenant } = await params
const { isEnabled: isDraft } = await draftMode()
// pass isDraft into metadata handlers and data fetchers
}
export default async function SlugPage({ params: paramsPromise }: Props) {
const { slug, tenant } = await paramsPromise
const { isEnabled: isDraft } = await draftMode()
// pass isDraft into route handlers and page query functions
}
The route then tries specialized handlers (product, careers, project, post, collection) and falls back to general page rendering. Every handler receives the same isDraft value. That keeps behavior consistent across all content types and avoids drift between preview and production implementations.
Once the route can detect draft state, your data layer must enforce the right caching behavior.
Step 4: Split the Data Layer into Draft-Uncached and Published-Cached Paths
The core data pattern is implemented in src/payload/db/index.ts. queryPageBySlug uses an internal fetcher wrapped in React cache for request dedupe, then applies unstable_cache only for published mode.
// File: src/payload/db/index.ts
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
const fetchPageInternal = cache(async (tenant: string, slug: string, draft: boolean) => {
const payload = await getPayload({ config: configPromise })
const where: Where = { and: [{ slug: { equals: slug } }] }
if (tenant) where.and?.push({ 'tenant.slug': { equals: tenant } })
if (!draft) where.and?.push({ hide: { equals: false } })
const { docs } = await payload.find({
collection: 'page',
overrideAccess: true,
draft,
depth: 1,
limit: 1,
where,
})
return docs.length > 0 ? (docs[0] as Page) : null
})
export const queryPageBySlug = async ({
tenant,
slug,
draft,
}: {
tenant: string
slug: string
draft?: boolean
}) => {
if (draft) {
return await fetchPageInternal(tenant, slug, true)
}
return await unstable_cache(
async () => await fetchPageInternal(tenant, slug, false),
[CACHE_KEY.PAGE_BY_SLUG(slug, tenant)],
{
tags: [TAGS.PAGES, CACHE_KEY.PAGE_BY_SLUG(slug, tenant)],
revalidate: false,
},
)()
}
This is the technical center of the whole design:
- Draft requests skip Next.js data caching and read fresh draft-aware content.
- Published requests use cached fetches and are invalidated by tags/paths on publish.
- The same query function handles both modes without duplicating route code.
The same draft-vs-published pattern is used across getPostBySlug, getProjectBySlug, getJobOpeningBySlug, getCollectionBySlug, getProductBySlug, and related design-data accessors. That consistency is what keeps behavior predictable.
Now that draft requests fetch fresh content, we need save-triggered refresh in the browser.
Step 5: Add Live Preview Refresh to Complete the Editor Loop
To reflect new draft saves immediately, this project uses @payloadcms/live-preview-react in a client component and calls router.refresh().
// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/refresh-route-on-save.tsx
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation.js'
export const RefreshRouteOnSave: React.FC<{ tenantSlug: string }> = ({ tenantSlug }) => {
const router = useRouter()
return (
<PayloadLivePreview
refresh={() => router.refresh()}
serverURL={finalPath}
/>
)
}
In the server route, this component is only rendered when Draft Mode is enabled:
// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx
{isDraft && <RefreshRouteOnSave tenantSlug={tenant} />}
This keeps preview tooling out of normal production traffic while giving editors immediate visual feedback after save events.
The last piece is making sure publish events invalidate cached output for public users.
Step 6: Revalidate Caches and Paths Only for Published Content
Publish-time invalidation is centralized in createCollectionRevalidationHook.
// File: src/payload/hooks/revalidateCollectionCache.ts
if (doc._status === 'draft') {
// Draft Mode uses request-time fetching
return
}
revalidateTag(tagName, 'max')
revalidatePath(path)
Collections register this factory in their afterChange hooks with path settings. For example, pages register:
// File: src/payload/collections/content/pages/hooks/revalidatePageCache.ts
export const revalidatePageCache = createCollectionRevalidationHook({
collectionSlug: 'page',
pathPrefix: 'tenant-slugs',
})
This separation is important. Saving a draft should not churn ISR caches, but publishing should invalidate both data tags and route paths. That gives editors smooth preview while keeping public caches accurate.
One more operational detail matters in this setup: tenant URL rewriting.
Step 7: Use Host-Based Tenant Rewrites While Keeping Draft Entry Separate
src/proxy.ts rewrites public URLs to internal tenant routes and intentionally excludes /api/*.
// File: src/proxy.ts
if (
tenant &&
!pathname.startsWith('/api') &&
!pathname.startsWith('/_next') &&
!pathname.startsWith('/admin')
) {
const slugPath = pathname === '/' ? '/home' : pathname
const newUrl = new URL(`/tenant-slugs/${tenant}${slugPath}`, req.url)
return NextResponse.rewrite(newUrl)
}
Because /api/draft is excluded, Draft Mode is enabled first. After redirect, the normal frontend path is rewritten into tenant-slugs/[tenant]/... and rendered through the same catch-all page. This is what lets preview and production share route files cleanly in a multi-tenant environment.
Conclusion
This implementation solves a specific but common problem: how to keep static/ISR performance for public traffic while supporting live draft previews for editors. The key was not adding a parallel preview route tree, but making one route draft-aware from top to bottom. Draft Mode controls request behavior, db accessors split cached and uncached paths, and publish hooks handle invalidation only when content goes live.
By the end of this setup, you can keep production pages fast and cache-friendly while editors preview draft changes on real routes with near real-time refresh.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija


