BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Ultimate Payload CMS Live Preview Guide for Next.js

Ultimate Payload CMS Live Preview Guide for Next.js

Implement secure, production-ready live previews with Next.js Draft Mode, signed preview tokens, multi-site locales…

6th September 2026·Updated on:14th September 2026··
Next.js
Ultimate Payload CMS Live Preview Guide for Next.js

Comparing Headless CMS Options?

Answer 10 simple questions and get an independent recommendation matched to your project, budget, and team structure.

Try the CMS PickerGet a Second Opinion

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

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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.

Contents

  • 1. Our use case
  • 2. Why the implementation is split into layers
  • 3. Prerequisites
  • 4. Configure a collection
  • Option A: collection-local Live Preview
  • Option B: root Live Preview
  • 5. Build canonical, secure preview URLs
  • 6. Enable Next.js Draft Mode
  • 7. Read the correct version
  • Cache Components
  • 8. Choose an iframe update strategy
  • A. Refresh Server Components after save
  • B. Merge form changes with `useLivePreview`
  • C. Hybrid
  • 9. Previewing Template Definitions
  • Keep a code-owned default template
  • 10. Configuration reference
  • `LivePreviewConfig`
  • `admin.preview`
  • Globals
  • React exports
  • 11. Security checklist
  • 12. Troubleshooting
  • No Live Preview control
  • No Preview button
  • Blank or refused iframe
  • Only published content appears
  • Changes do not update
  • Wrong Site or language
  • Stale Cache Components output
  • Template preview does not change
  • 13. Test plan
  • 14. Decision guide
  • 15. References
On this page:
  • 1. Our use case
  • 2. Why the implementation is split into layers
  • 3. Prerequisites
  • 4. Configure a collection
  • 5. Build canonical, secure preview URLs
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

Live Preview looks simple in a demo: put a URL in a collection config, open an iframe, and show the draft. A production implementation has more moving parts. The CMS must know the correct public URL, the frontend must deliberately enter draft mode, draft reads must bypass published caches, and neither the preview URL nor the iframe may weaken tenant or locale isolation.

This guide starts with the implementation used by our multi-site website, then turns it into a reusable setup for Payload CMS and the Next.js App Router. It also covers template-definition records, which describe presentation but do not have a public page of their own.

The examples reflect Payload 3.88.0, @payloadcms/live-preview-react 3.88.0, and Next.js 16.3.3. Check the reference links at the end when using a later version.

1. Our use case

Our application has all of the conditions that make preview URLs more than a string interpolation exercise:

  • one Payload installation serves multiple sites;
  • each site can enable a different set of locales;
  • English and French use different route segments, such as /en-CA/blog/... and /fr-CA/blogue/...;
  • published pages use Next.js Cache Components, while drafts must remain request-time and uncached;
  • Blogs, Pages, Recipes, Giveaways, and Products share Live Preview;
  • a separate template-definitions collection selects approved, code-owned presentation variants;
  • editors need both the in-admin iframe and a normal Preview button.

For a Blog document, the flow is:

Diagram

The Blog collection supplies a conventional admin.preview callback:

ts
admin: {
  preview: (data, { locale, req }) =>
    buildLivePreviewUrl({
      kind: "blog",
      locale,
      req,
      site: data.site,
      slug: typeof data.slug === "string" ? data.slug : "",
    }),
}

The shared admin config enables the iframe for several collections:

ts
admin: {
  livePreview: {
    collections: ["pages", "blogs", "recipes", "giveaways", "products"],
    url: ({ collectionConfig, data, locale, req }) => {
      const kind = collectionConfig
        ? kindByCollection[collectionConfig.slug]
        : undefined

      if (!kind || !locale?.code) return null

      return buildLivePreviewUrl({
        kind,
        locale: locale.code,
        req,
        site: data.site,
        slug: typeof data.slug === "string" ? data.slug : "home",
      })
    },
  },
}

Both controls use the same routing and security rules, but they are separate:

FeatureConfigurationEditor experience
Previewadmin.previewOpens the generated URL as a normal page/tab
Live Previewadmin.livePreview or collection/global admin.livePreviewEmbeds the URL in Payload's preview panel and sends update events

Configuring one does not automatically configure the other.

2. Why the implementation is split into layers

A robust preview system has five responsibilities:

  1. Payload drafts and versions preserve unpublished state.
  2. The URL builder converts CMS state into the correct public site, locale, and route.
  3. The draft endpoint authenticates the request and enables Next.js Draft Mode.
  4. The data loader selects draft or published records without mixing their cache behavior.
  5. The frontend bridge refreshes a Server Component route after saves or merges real-time form state in a Client Component.

This separation prevents a common mistake: treating the appearance of a Live Preview toggle as proof that unpublished content is fetched safely. The Admin UI is only the entry point; the server query remains the security boundary.

3. Prerequisites

Install the React bridge if needed:

bash
pnpm add @payloadcms/live-preview-react

Its version should normally match Payload. Enable drafts on each collection:

ts
import type { CollectionConfig } from "payload";

export const Blogs: CollectionConfig = {
  slug: "blogs",
  versions: {
    drafts: {
      autosave: { interval: 800 },
    },
  },
  fields: [
    { name: "title", type: "text", localized: true, required: true },
    { name: "slug", type: "text", localized: true, required: true },
    { name: "body", type: "richText", localized: true },
  ],
};

Live Preview works without autosave, but server refresh only shows saved state. Autosave makes that approach feel live while preserving Server Component rendering.

Configure the frontend and secrets:

dotenv
SERVER_URL=https://www.example.com
NEXT_PUBLIC_SERVER_URL=https://www.example.com
PAYLOAD_SECRET=your-existing-payload-application-secret
PREVIEW_SECRET=a-different-random-secret-with-at-least-32-characters

Generate a secret with openssl rand -base64 48. Never place PAYLOAD_SECRET in a query string: it signs Payload authentication data. Use a separate PREVIEW_SECRET and store production values in a secret manager.

4. Configure a collection

Option A: collection-local Live Preview

Use this when a collection has unique routing logic:

ts
export const Blogs: CollectionConfig = {
  slug: "blogs",
  admin: {
    livePreview: {
      openByDefault: false,
      breakpoints: [
        { name: "mobile", label: "Mobile", width: 390, height: 844 },
        { name: "tablet", label: "Tablet", width: 768, height: 1024 },
        { name: "desktop", label: "Desktop", width: 1440, height: 900 },
      ],
      url: ({ data, locale, req }) => {
        if (!locale?.code || typeof data.slug !== "string") return null;
        return buildLivePreviewUrl({
          kind: "blog",
          locale: locale.code,
          req,
          site: data.site,
          slug: data.slug,
        });
      },
    },
    preview: (data, { locale, req }) => {
      if (typeof data.slug !== "string") return null;
      return buildLivePreviewUrl({
        kind: "blog",
        locale,
        req,
        site: data.site,
        slug: data.slug,
      });
    },
  },
  versions: { drafts: true },
  fields: [],
};

The locale shapes differ: livePreview.url receives a locale object, while preview receives the locale code as a string. Either callback may return a URL, null, or a promise. Return null when required context is missing.

Option B: root Live Preview

Use root configuration when many collections share one URL policy:

ts
const kindByCollection: Partial<Record<string, "page" | "blog" | "recipe">> = {
  pages: "page",
  blogs: "blog",
  recipes: "recipe",
};

export const config: Config = {
  admin: {
    livePreview: {
      collections: Object.keys(kindByCollection),
      openByDefault: false,
      breakpoints: [
        { name: "mobile", label: "Mobile", width: 390, height: 844 },
        { name: "desktop", label: "Desktop", width: 1440, height: 900 },
      ],
      url: ({ collectionConfig, data, locale, req }) => {
        if (!collectionConfig || !locale?.code) return null;
        const kind = kindByCollection[collectionConfig.slug];
        if (!kind || typeof data.slug !== "string") return null;
        return buildLivePreviewUrl({
          kind,
          locale: locale.code,
          req,
          site: data.site,
          slug: data.slug,
        });
      },
    },
  },
  collections: [Blogs],
};

Prefer a typed lookup or switch over known slugs in application code. Root configuration adds collections and globals target lists. Collection and global configs already know their target. A local config can override root behavior for that entity.

5. Build canonical, secure preview URLs

A single-site tutorial can interpolate /posts/${slug}. A production URL builder should validate and bind the routing context:

ts
export async function buildLivePreviewUrl(input: {
  kind: "page" | "blog" | "recipe";
  locale: string;
  req: PayloadRequest;
  site: unknown;
  slug: string;
}): Promise<string | null> {
  const site = await resolveAuthorizedSite(input.site, input.req);
  if (!site || !site.enabledLocales.includes(input.locale)) return null;

  const origin = resolveTrustedOrigin(site);
  if (!origin) return null;

  const path = buildCanonicalPublicPath({
    kind: input.kind,
    locale: input.locale,
    slug: input.slug,
  });
  const token = createShortLivedPreviewToken({
    origin,
    path,
    secret: process.env.PREVIEW_SECRET,
  });

  return token ? `${origin}/api/draft?${new URLSearchParams({ token })}` : null;
}

Those helper contracts should enforce:

  • the Site exists and the current user may read it;
  • the Site and requested locale are enabled;
  • there is no silent locale fallback;
  • the origin comes from trusted configuration, never a browser Referer;
  • the path uses the same canonical route registry as the frontend;
  • the token expires and binds the exact origin and path.

Our token is HMAC-SHA256 signed and includes { origin, path, exp, version }. Verification uses a timing-safe comparison, rejects unsafe paths, and refuses a token presented on another origin. JWT is also valid if it enforces the same claims.

Signing only a shared secret while accepting arbitrary ?slug= input still permits an open redirect. Redirect to the path recovered from the verified token or from a verified CMS record.

6. Enable Next.js Draft Mode

The entry endpoint verifies the token, sets Next.js's cookie, and redirects to the verified public path. Our app registers it as a Payload custom endpoint:

ts
import { draftMode } from "next/headers";
import { connection } from "next/server";
import type { Endpoint } from "payload";

export const draftEndpoint: Endpoint = {
  path: "/draft",
  method: "get",
  handler: async (req) => {
    await connection();
    const requestURL = req.url ? new URL(req.url) : null;
    const origin = resolveValidatedRequestOrigin(req);
    if (!requestURL || !origin) {
      return new Response("Invalid preview request", { status: 400 });
    }

    const result = verifyPreviewToken({
      origin,
      secret: process.env.PREVIEW_SECRET,
      token: requestURL.searchParams.get("token"),
    });
    if (!result.valid) {
      return new Response("Invalid preview token", {
        status: 401,
        headers: { "Cache-Control": "no-store" },
      });
    }

    const draft = await draftMode();
    draft.enable();
    return new Response(null, {
      status: 307,
      headers: {
        "Cache-Control": "no-store",
        "Referrer-Policy": "no-referrer",
        Location: new URL(result.path, result.origin).toString(),
      },
    });
  },
};

resolveValidatedRequestOrigin must validate forwarded protocol and host headers for your proxy topology. Payload endpoints sit below its API route, so with the default /api, path: "/draft" becomes /api/draft.

A Next.js Route Handler at app/api/draft/route.ts is the common alternative. Use a Payload endpoint for one API surface and Payload request context; use a Route Handler when CMS and frontend are separate or that matches the existing architecture. The validation contract is identical. Do not implement both without a reason.

Cookie-changing operations normally belong to POST. CMS preview links and iframe sources are navigations, so they commonly need a GET entry. Authenticate it, keep tokens short-lived, mutate no application data, and send no-store.

Provide an exit endpoint or Server Action using:

ts
const draft = await draftMode();
draft.disable();

Set prefetch={false} on an exit <Link> so prefetching cannot disable the cookie unexpectedly.

7. Read the correct version

In Next.js 16, draftMode() is asynchronous:

tsx
export default async function BlogPage({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const { isEnabled } = await draftMode();
  const post = await getBlogBySlug({ slug, draft: isEnabled });
  if (!post) notFound();
  return <BlogTemplate document={post} />;
}

Switch Payload version behavior explicitly:

ts
const result = await payload.find({
  collection: "blogs",
  depth: 1,
  draft: input.draft,
  fallbackLocale: false,
  limit: 1,
  locale: input.locale,
  overrideAccess: true,
  select: {
    body: true,
    featuredMedia: true,
    publishedAt: true,
    slug: true,
    summary: true,
    title: true,
  },
  where: {
    and: [
      { "site.slug": { equals: input.siteSlug } },
      { slug: { equals: input.slug } },
      ...(input.draft ? [] : [{ _status: { equals: "published" } }]),
    ],
  },
});

Use overrideAccess: true only for trusted server frontend code that enforces site, locale, slug, and publication rules independently. Local API work on behalf of a user should use overrideAccess: false and pass req.

Return a narrow presentation projection, not a raw Payload document. Match relationship depth to the renderer and handle relationships that are IDs, missing, or inaccessible.

Cache Components

Make the draft/published boundary explicit:

tsx
async function DraftModeGate(props: PageInput) {
  const { isEnabled } = await draftMode();
  return isEnabled ? (
    <UncachedDraftPage {...props} />
  ) : (
    <CachedPublishedPage {...props} />
  );
}

Our router puts this below <Suspense>, calls await connection() in the draft branch, and keeps published loaders in dedicated "use cache" functions. Next.js Draft Mode bypasses the fetch cache, Cache Components, unstable_cache, and ISR for the request. isEnabled is readable in a cache scope, but enable() and disable() must run outside one.

8. Choose an iframe update strategy

A. Refresh Server Components after save

This uses the exact production renderer:

tsx
"use client";

import { RefreshRouteOnSave } from "@payloadcms/live-preview-react";
import { useRouter } from "next/navigation";

export function LivePreviewRefresh() {
  const router = useRouter();
  return (
    <RefreshRouteOnSave
      refresh={() => router.refresh()}
      serverURL={process.env.NEXT_PUBLIC_SERVER_URL ?? ""}
    />
  );
}

Mount it only in Draft Mode. With autosave, Payload saves the version, emits a document event, and the bridge refreshes the route.

Advantages: Server Components work unchanged; relationship population, hooks, and transforms come from a real Payload read; preview exercises production routing. Tradeoffs: updates wait for save/autosave, include server latency, and require the serverURL to match the Admin event origin.

B. Merge form changes with useLivePreview

Use a Client Component for character-by-character feedback:

tsx
"use client";

import type { Blog } from "@payload-types";
import { useLivePreview } from "@payloadcms/live-preview-react";

type BlogPreview = Pick<Blog, "body" | "title">;

export function LiveBlogPreview({ initialData }: { initialData: BlogPreview }) {
  const { data, isLoading } = useLivePreview<BlogPreview>({
    initialData,
    serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? "",
    depth: 1,
  });

  return (
    <article aria-busy={isLoading}>
      <h1>{data.title}</h1>
      {/* Render the live document state. */}
    </article>
  );
}
OptionRequiredPurpose
initialDataYesServer-fetched document beneath incoming changes
serverURLYesTrusted Payload origin whose messages are accepted
depthNoRelationship population depth
apiRouteNoNon-default Payload API route
requestHandlerNoCustom population handler for proxying or middleware

The hook returns { data, isLoading }. It gives immediate field changes, but the preview must be client-side; server-only presentation needs a boundary; and relationships/uploads still need careful population and ID-only handling. Posted preview data is never authorization for a server mutation.

C. Hybrid

A hybrid can use the hook for immediate local fields and refresh after saves for relationships and derived output. It is richer but creates two preview state sources. Define which source wins after save before adopting it.

9. Previewing Template Definitions

A Template Definition selects a layout; it is not itself a public article. Preview a real document through the draft definition:

ts
export const TemplateDefinitions: CollectionConfig = {
  slug: "template-definitions",
  admin: {
    livePreview: {
      url: ({ data, locale, req }) =>
        buildTemplateLivePreviewUrl({
          data,
          locale: locale?.code ?? "",
          req,
        }),
    },
    preview: (data, { locale, req }) =>
      buildTemplateLivePreviewUrl({ data, locale, req }),
  },
  versions: { drafts: true },
  fields: [],
};

The helper should:

  1. Validate contentType against a closed, code-owned list.
  2. Resolve and authorize the exact Site and locale.
  3. Map content type to a Payload collection.
  4. Find one representative document using draft: true, fallbackLocale: false, overrideAccess: false, and the current req.
  5. Build its canonical public route.
  6. Sign the origin and path like any other preview.

The redirected draft page then queries both the content and Template Definition in draft mode.

Keep a code-owned default template

Definitions select variants; they must not enable basic rendering:

ts
export const blogTemplates = createTemplateRegistry("blog", {
  default: BlogTemplateDefault,
  editorial: BlogTemplateEditorial,
});

const definition = await getTemplateDefinition({
  contentType: "blog",
  draft,
  locale,
  siteSlug,
});
const Template = blogTemplates.resolve(definition?.templateKey);

Resolve missing or unknown keys to default. This protects new sites, incomplete seeds, removed configuration, and preview sessions.

If no representative document exists, deliberately return null, use a code-owned fixture, or render a dedicated template sandbox. Never borrow a document from another Site or locale.

10. Configuration reference

These types reflect Payload 3.88.0.

LivePreviewConfig

PropertyTypeMeaning
urlURL value or sync/async callbackIframe source; null/undefined suppresses preview
breakpoints{ name, label, width, height }[]Toolbar devices; dimensions accept numbers or strings; responsive is included by default
openByDefaultbooleanOpens preview on first document view; stored user preference later wins; default false

The URL callback receives data, locale, req, optional collectionConfig, optional globalConfig, and deprecated payload (use req.payload). It may run often with autosave, so avoid expensive work.

Root admin.livePreview adds collections?: string[] and globals?: string[].

admin.preview

Its signature is:

ts
type GeneratePreviewURL = (
  doc: Record<string, unknown>,
  options: {
    locale: string;
    req: PayloadRequest;
    token: string | null;
  },
) => string | null | Promise<string | null>;

Use req for authorized reads. Do not copy Payload's auth token into a public URL; issue a purpose-specific preview token.

Globals

Globals support local admin.livePreview and admin.preview. Because one global may affect many routes, choose a fixed sandbox, homepage, or representative page that visibly uses it.

React exports

@payloadcms/live-preview-react exports:

  • RefreshRouteOnSave, with required refresh and serverURL, plus optional apiRoute and depth;
  • useLivePreview, with the options above.

The lower-level @payloadcms/live-preview package exposes subscribe, unsubscribe, and ready. Prefer the React wrapper unless you need to own the event lifecycle.

11. Security checklist

  • Drafts/versions are enabled.
  • URL callbacks derive Site and locale on the server.
  • Site reads respect request access.
  • The locale is enabled and fallbackLocale: false preserves isolation.
  • Origins come from a trusted allowlist/host map, not Referer.
  • PAYLOAD_SECRET never appears in a URL.
  • PREVIEW_SECRET is separate, random, long, and secret-managed.
  • Tokens expire and bind the exact origin and safe relative path.
  • Signatures use safe comparison and the endpoint cannot open-redirect.
  • Proxy host/protocol handling matches the deployment topology.
  • Redirects send no-store and no-referrer.
  • Draft loaders cannot cross Site or locale boundaries.
  • Public loaders explicitly require published visibility.
  • Client Components receive narrow projections, not raw documents.
  • CSP, frame, cookie, and proxy policies allow the intended iframe.

In our staging environment, PREVIEW_SECRET is managed in Infisical with the other runtime secrets.

12. Troubleshooting

No Live Preview control

Register the collection in root collections or add local livePreview. Configuring only preview creates no iframe control. Restart after config changes.

No Preview button

Add admin.preview; Live Preview does not create it. A new document may lack Site, locale, or slug, in which case returning null is correct.

Blank or refused iframe

Inspect the URL/response, Content-Security-Policy frame-ancestors, X-Frame-Options, cookie policy, public origins, and reverse-proxy headers.

Only published content appears

Confirm draftMode().isEnabled, draft: true, removal of public-only _status conditions in draft mode, and the existence of a draft version.

Changes do not update

For server refresh, mount the bridge in draft mode, match NEXT_PUBLIC_SERVER_URL, and confirm save/autosave runs. For the hook, ensure the component uses returned data, not only initialData.

Wrong Site or language

Scope every query by trusted Site context, use fallbackLocale: false, and build localized paths from one route registry. Never derive tenant ownership from a fetched content document.

Stale Cache Components output

Keep preview branching close to draftMode(), do not send draft input through published cache functions, and do not application-cache draft query results.

Template preview does not change

Query the definition with draft: true; verify the representative document's Site/locale; ensure the key exists; retain the default registry entry.

13. Test plan

  1. Intended collections expose the iframe and, separately, Preview button.
  2. English and French URLs use their exact localized routes.
  3. Disabled Sites, bad locales, missing slugs, and untrusted origins return null.
  4. Valid tokens enable Draft Mode and redirect only to their bound path.
  5. Tampered, expired, wrong-origin, malformed, and unsafe-path tokens fail.
  6. Draft queries render unpublished documents; public queries do not.
  7. Site and locale isolation hold in both modes.
  8. Save/autosave events refresh the Server Component route.
  9. Missing/unknown template keys resolve to the code-owned default.
  10. Template previews use only same-Site, same-locale representatives.

Test the workflow in a browser too. Unit tests cannot reveal iframe policy, cookie partitioning, proxy headers, or responsive-toolbar problems.

14. Decision guide

SituationRecommended setup
One collection, one routeLocal admin.livePreview plus admin.preview
Many collections share routingRoot config with typed collection-to-route mapping
Mostly Server ComponentsRefreshRouteOnSave and autosave
Immediate typing feedbackuseLivePreview Client Component
Relationships/server transforms dominateServer refresh or a carefully defined hybrid
Config record has no public routeSame-context representative or dedicated sandbox
Multi-site/multilingualServer-resolved origin and canonical route; no fallback
Optional template selectorCode-owned default plus configured variants

15. References

  • Payload Live Preview overview
  • Payload frontend integration
  • Payload drafts
  • Payload custom endpoints
  • Next.js Draft Mode guide
  • Next.js draftMode API
  • Next.js Cache Components

Related repository files:

  • src/payload/config/admin.ts — shared iframe registration
  • src/payload/collections/content/blogs/Blogs/index.ts — Blog Preview button
  • src/payload/collections/content/site-chrome/TemplateDefinitions/index.ts — Template Definition preview
  • src/payload/utilities/preview-url.ts — Site/locale/origin resolution
  • src/payload/utilities/preview-token.ts — signed tokens
  • src/payload/endpoints/draft-mode.ts — Draft Mode entry and exit
  • src/routing/routed-page.tsx — draft/published boundary
  • src/components/live-preview/refresh-route-on-save.tsx — refresh bridge
  • src/components/templates/shared/create-registry.ts — template fallback

The essential model is: Payload decides where to preview, the entry endpoint decides whether the request is trusted, Next.js Draft Mode decides which cache behavior applies, and the data layer decides which version may render. Treat those as explicit contracts and preview stays predictable as the app grows.