• Home
BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. 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…

4th March 2026·Updated on:22nd February 2026·MŽMatija Žiberna·
Next.js
Next.js Draft Mode + ISR: Single-Route Live Previews

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

No spam. Unsubscribe anytime.

Related Posts:

  • •Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes
  • •How to Use Canonical Tags and Hreflang in Next.js 16
  • •Reduce Next.js Bundle Size: Proven Fixes to Trim 476KB

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:

  1. Draft requests skip Next.js data caching and read fresh draft-aware content.
  2. Published requests use cached fetches and are invalidated by tags/paths on publish.
  3. 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

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes
Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes

5th March 2026

How to Use Canonical Tags and Hreflang in Next.js 16
How to Use Canonical Tags and Hreflang in Next.js 16

6th September 2025

Reduce Next.js Bundle Size: Proven Fixes to Trim 476KB
Reduce Next.js Bundle Size: Proven Fixes to Trim 476KB

4th February 2026

Table of Contents

  • The Problem Setup
  • Step 1: Build a Single Route Entry for Tenant Pages
  • Step 2: Enable Draft Mode Through a Secure API Entry Point
  • Step 3: Branch Behavior by Draft Mode Inside the Same Page Route
  • Step 4: Split the Data Layer into Draft-Uncached and Published-Cached Paths
  • Step 5: Add Live Preview Refresh to Complete the Editor Loop
  • Step 6: Revalidate Caches and Paths Only for Published Content
  • Step 7: Use Host-Based Tenant Rewrites While Keeping Draft Entry Separate
  • Conclusion
On this page:
  • The Problem Setup
  • Step 1: Build a Single Route Entry for Tenant Pages
  • Step 2: Enable Draft Mode Through a Secure API Entry Point
  • Step 3: Branch Behavior by Draft Mode Inside the Same Page Route
  • Step 4: Split the Data Layer into Draft-Uncached and Published-Cached Paths
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved