How to set-up livePreview in Payload with Nextjs's draftMode

Use Next.js Draft Mode with Payload CMS to enable ISR pages, instant editor previews, and a single unified route.

·Matija Žiberna·
How to set-up livePreview in Payload with Nextjs's draftMode

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

I was building a multi-tenant site with Next.js and Payload CMS when I hit a common wall: the "Static vs. Dynamic" preview dilemma. Most official examples for CMS integrations either assume your pages are always dynamic or suggest maintaining separate routes for production and preview. The first kills your performance; the second is a maintenance nightmare.

After some experimentation, I discovered that Next.js native Draft Mode is the "stronger case" that solves both. It allows you to keep your production site strictly static (ISR) while enabling a seamless, dynamic preview experience—without duplicating a single route. This guide walks you through the implementation I developed to bridge Payload CMS and high-performance Next.js apps.

The Problem: The Static/Dynamic Paradox

The goal is usually to have lightning-fast, statically generated pages for production. However, content editors need to see their changes instantly. There are two "standard" but flawed approaches to this:

  1. Always Dynamic Pages: You use force-dynamic or disable caching entirely. This makes previews easy but ruins production speed and scalability.
  2. Dual Route Architecture: You maintain /blog/[slug] for production and /preview/blog/[slug] for editors. You end up duplicating logic, wrapping handlers, and fighting technical debt.

Neither of these is ideal. The official Payload examples often lean towards the first approach, assuming the page can be refreshed easily because it's dynamic. But in a real-world, performance-first app, you want Static Generation.

The Solution: Next.js Draft Mode

Next.js draftMode provides a secure way to bypass the static cache on a per-request basis. This means we can use the exact same route for both production and preview. When Draft Mode is off, the user gets a pre-rendered static page. When it's on, Next.js sees a bypass cookie and fetches fresh data directly from Payload.

To make this truly enterprise-grade, we also need to solve the "Double Fetch" problem. If your generateMetadata and Page component both fetch the same document, you're hitting the database twice. We'll use React's cache to ensure we only query Payload once per request.

Step 1: The Draft Mode API Route

The first piece of the puzzle is a secure entry point that tells Next.js to bypass the static cache. This route validates a secret token and sets the necessary cookies.

// 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 || !slug) {
    return new Response('Invalid token', { status: 401 });
  }

  (await draftMode()).enable();
  
  redirect(slug);
}

This handler is straightforward: it checks if the request is authorized via the PAYLOAD_SECRET defined in your environment variables. Once validated, it calls draftMode().enable(), which sets a __prerender_bypass cookie. Next.js sees this cookie and treats future requests as dynamic, always fetching fresh data instead of using the static cache.

Step 2: Unified Routing Logic

Instead of two separate routes, we use one production route that dynamically detects if Draft Mode is enabled.

// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx
import { draftMode } from "next/headers";
import { queryPageBySlug } from "@/payload/db";

export default async function SlugPage({ params }) {
  const { slug, tenant } = await params;
  const { isEnabled: isDraft } = await draftMode();

  const page = await queryPageBySlug({ 
    slug, 
    tenant, 
    draft: isDraft 
  });

  if (!page) return notFound();

  return (
    <main>
      {isDraft && <RefreshRouteOnSave tenantSlug={tenant} />}
      <RenderBlocks blocks={page.layout} />
    </main>
  );
}

By checking draftMode().isEnabled, we determine whether to pass the draft: true flag to our database fetchers. This approach keeps your code DRY (Don't Repeat Yourself) and ensures that your production environment is identical to your preview environment, minus the cache bypass.

Step 3: Advanced Caching and Request Deduplication

This is where we solve the performance bottleneck. Even with Draft Mode, we don't want to hit the database multiple times for the same slug in a single request cycle. We'll use React's cache function combined with a serialized query pattern to ensure stable deduping.

// File: src/payload/db/index.ts
import { cache } from 'react';
import { unstable_cache } from 'next/cache';

// The "Internal" fetcher is wrapped in React cache for request deduplication
const fetchPageInternal = cache(async (slug: string, tenant: string, draft: boolean) => {
  const payload = await getPayloadClient();
  
  const { docs } = await payload.find({
    collection: "page",
    where: {
      and: [
        { slug: { equals: slug } },
        { "tenant.slug": { equals: tenant } }
      ]
    },
    draft,
    limit: 1,
  });

  return docs[0] || null;
});

export const queryPageBySlug = async ({ slug, tenant, draft }) => {
  // If it's a draft, skip Next.js static cache entirely
  if (draft) {
    return await fetchPageInternal(slug, tenant, true);
  }

  // If published, use Next.js unstable_cache for ISR/Static generation
  return await unstable_cache(
    async () => fetchPageInternal(slug, tenant, false),
    [`page-${tenant}-${slug}`],
    { tags: ['pages'], revalidate: 3600 }
  )();
};

Here, fetchPageInternal is responsible for the actual database work. By wrapping it in cache, React ensures that if queryPageBySlug is called multiple times with the same arguments (even across generateMetadata and your Page component), the database is only queried once.

We also optimized the database query itself by using a direct where clause on the slug field. In earlier versions of this project, we were fetching all pages and filtering in JavaScript—an "O(n)" operation that was adding seconds to the load time. Direct filtering is "O(1)" with a database index, making it nearly instantaneous.

Step 4: The Live Preview Bridge

To make the preview feel truly "live," we need to bridge the gap between Payload's admin interface and our Next.js frontend. We use a client component that listens for save events and triggers a router refresh.

// File: src/components/refresh-route-on-save.tsx
'use client'
import { useRouter } from 'next/navigation'
import { PayloadLivePreview } from '@payloadcms/live-preview-react'

export const RefreshRouteOnSave = ({ tenantSlug }) => {
  const router = useRouter()
  return (
    <PayloadLivePreview
      refresh={() => router.refresh()}
      serverURL={process.env.NEXT_PUBLIC_PAYLOAD_URL}
    />
  )
}

This component uses the @payloadcms/live-preview-react library. Whenever you save a document in Payload, the refresh callback is triggered. Because Draft Mode is enabled, router.refresh() will tell Next.js to re-fetch the server components, providing an updated view of the content without a full page reload.

Conclusion

By moving away from dual-route architectures and embracing Next.js native Draft Mode, we've built a system that is both easier to maintain and faster to execute. The combination of React cache for request deduplication and direct database filtering ensures that even the most complex pages load in under a second.

This setup gives you the best of both worlds: highly cached, static production pages and a dynamic, lightning-fast preview experience for your editors.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

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.