首页
/ Payload Draft Preview 实践:基于 Versions、Drafts 与 Next.js Draft Mode 的内容预发布预览方案

Payload Draft Preview 实践:基于 Versions、Drafts 与 Next.js Draft Mode 的内容预发布预览方案

2026-09-05 12:58:33作者:虞亚竹Luna

本文围绕 Payload 官方示例 examples/draft-preview 展开,讲解 Draft Preview(草稿预览)的完整实现链路:从后台点击 Preview 按钮、携带 secret 跳转前端、校验身份并进入预览模式,到前端以 draft=true 拉取草稿内容、发布后按需重新生成静态页(On-demand Revalidation)。读完本文,你将能够基于该示例在自己的 Payload + Next.js 项目中落地一套"发布前先预览"的工作流,并理解其中访问控制、CORS/CSRF 安全配置与 revalidation 钩子的具体实现细节。

一、Draft Preview 是什么

Draft Preview 是 Payload 管理面板提供的一项能力:开启 Versions(版本管理)中的 Drafts(草稿)后,编辑人员在后台保存的文档可以是 draft 状态,未发布前公众不可见。通过 Draft Preview,你可以从后台的 "Preview" 按钮直接跳转到自己的前端站点并进入 "draft mode",此时查询会被修改为拉取草稿内容而非已发布内容,从而在发布前看到内容在前端上的真实渲染效果。

整个机制的核心思想可以概括为一句话:用户带着自己的 http-only cookie(身份凭证)和一个 secret(一次性校验凭证)被重定向到前端;前端 API 路由校验两者后进入预览模式;此后前端即可携带 Authorization 头安全地请求 Payload 中的草稿文档

该示例基于 Next.js App Router 实现,相关概念在仓库文档中有对应说明:草稿预览概述版本管理Drafts

二、Quick Start:把示例跑起来

以下是示例 README 给出的完整启动步骤,可直接复制执行:

  1. 用脚手架基于该示例创建项目:

    npx create-payload-app --example draft-preview
    
  2. 复制环境变量模板:

    cp .env.example .env
    
  3. 确保 MongoDB 已运行,并将 DATABASE_URL 指向它,例如:

    mongodb://127.0.0.1/payload-example-draft-preview
    
  4. 启动开发服务器(三者任选其一):

    pnpm dev
    # 或 yarn dev / npm run dev
    
  5. 打开 http://localhost:3000/admin 进入管理面板;

  6. 使用邮箱 demo@payloadcms.com、密码 demo 登录。

package.json 可以看到,dev 脚本实际是 pnpm seed && next dev,即启动前会先执行 seed 脚本初始化数据库(详见下文 Seed 一节);seed 脚本本身是 payload migrate:fresh。该示例依赖 payload@latestnext@^15.4.10@payloadcms/next@payloadcms/db-mongodb@payloadcms/richtext-slate 以及 @payloadcms/admin-bar,Node 引擎要求 ^18.20.2 || >=20.9.0

三、集合设计:Users 与 Pages

示例的 Payload 配置入口是 payload.config.ts,其中注册了两个集合与一个全局文档:

export default buildConfig({
  collections: [Pages, Users],
  cors: [process.env.NEXT_PUBLIC_SERVER_URL || ''].filter(Boolean),
  csrf: [process.env.NEXT_PUBLIC_SERVER_URL || ''].filter(Boolean),
  db: mongooseAdapter({
    url: process.env.DATABASE_URL || '',
  }),
  editor: slateEditor({}),
  globals: [MainMenu],
  secret: process.env.PAYLOAD_SECRET || '',
  // ...
})

3.1 Users 集合:预览时的身份来源

users 集合启用了 auth,提供管理面板登录能力。关键在于:在前端预览文档时,使用的是当前登录用户的 JWT 来通过 Payload 的鉴权——这正是草稿访问控制能被安全"绕过"的前提。鉴权细节可参考仓库文档 Authentication 概述 或官方 Auth 示例

3.2 Pages 集合:drafts 开启 + 访问控制

Pages 集合(src/collections/Pages/index.ts)是 Draft Preview 的核心载体,其配置要点如下:

export const Pages: CollectionConfig = {
  slug: 'pages',
  access: {
    create: loggedIn,
    delete: loggedIn,
    read: publishedOrLoggedIn,   // 只读操作:已发布,或已登录
    update: loggedIn,
  },
  admin: {
    defaultColumns: ['title', 'slug', 'updatedAt'],
    preview: ({ slug, collection }: { slug: string; collection: CollectionSlug }) => {
      const encodedParams = new URLSearchParams({
        path: `/${slug}`,
        previewSecret: process.env.PREVIEW_SECRET || '',
      } satisfies PreviewSearchParams)

      return `${process.env.NEXT_PUBLIC_SERVER_URL}/preview?${encodedParams.toString()}`
    },
    useAsTitle: 'title',
  },
  fields: [
    { name: 'title', type: 'text', required: true },
    {
      name: 'slug',
      type: 'text',
      admin: { position: 'sidebar' },
      hooks: { beforeValidate: [formatSlug('title')] },
      index: true,
      label: 'Slug',
    },
    richText(),
  ],
  hooks: {
    afterChange: [revalidatePage],   // 按需重新验证(见第六节)
  },
  versions: {
    drafts: true,                    // 开启草稿
  },
}

四个要点:

  • versions: { drafts: true }:开启后该集合的文档拥有 _status 字段(draft / published),后台会出现 "Save Draft"、"Publish"、"Preview" 等按钮。
  • admin.preview 函数:即文档中所说的 preview function。它拼出前端的预览路由 URL,并把 path(该文档在前端的相对路径 /${slug})与 previewSecret(环境变量 PREVIEW_SECRET)作为 query 参数传给前端。
  • 访问控制read 使用 publishedOrLoggedIn,这是防止未登录用户读到草稿的关键。
  • afterChange 钩子 revalidatePage:发布后触发前端静态页重新生成,详见第六节。

3.3 访问控制的两个函数

publishedOrLoggedInaccess/publishedOrLoggedIn.ts)的实现是"返回访问控制对象"的典型案例——它没有简单返回布尔值,而是返回一个 where 查询来追加过滤条件

import type { Access } from 'payload'

export const publishedOrLoggedIn: Access = ({ req: { user } }) => {
  if (user) {
    return true
  }

  return {
    or: [
      {
        _status: {
          equals: 'published',
        },
      },
    ],
  }
}

含义是:请求方已登录则放行全部(包括 draft);否则强制在查询条件中附加 _status = publishedloggedInaccess/loggedIn.ts)则更简单,直接 return Boolean(user),用于 create/update/delete。

3.4 前端如何拉取草稿文档

README 给出的前端取数模式是:进入预览模式后,请求 Payload REST API 时带上 draft=true 查询参数与 Authorization 头(值为当前用户的 Payload JWT),以通过上文的草稿访问控制:

const preview = true // set this based on your own front-end environment (see `Preview Mode` below)
const pageSlug = 'example-page' // same here
const searchParams = `?where[slug][equals]=${pageSlug}&depth=1${preview ? `&draft=true` : ''}`

// when previewing, send the payload token to bypass draft access control
const pageReq = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages${searchParams}`, {
  headers: {
    ...(preview
      ? {
          Authorization: `JWT ${payloadToken}`,
        }
      : {}),
  },
})

在本示例中,前端是 Next.js App Router 的服务端组件,取数逻辑写在 app/(app)/[slug]/page.tsx 中,通过 Local API 完成同样的事情——用 draftMode() 判断是否处于预览态,然后把 draftoverrideAccess 一并传给 payload.find

const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
  const { isEnabled: draft } = await draftMode()

  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'pages',
    draft,                // 预览模式下拉取最新版本(草稿)
    limit: 1,
    overrideAccess: draft, // 预览模式下跳过访问控制
    where: {
      slug: {
        equals: slug,
      },
    },
  })

  return result.docs?.[0] || null
})

同一文件中,generateStaticParams构建期draft: false + overrideAccess: false 拉取所有已发布页面(排除 home),生成静态路由——也就是说构建产物天然只包含公开内容,草稿绝不会泄漏进静态 HTML。

四、Preview Mode 的完整链路

README 对 Preview Mode 的描述是:用户先至少保存一份草稿文档,然后在管理面板点击 "Preview" 按钮;Payload 调用 admin.preview 函数生成的 URL 把用户路由到前端,URL 上带有 secret,同时浏览器带着用户的 http-only cookie;前端的 API 路由校验 secret 与 token 后进入预览模式。下面按请求顺序拆解本示例中的实现。

4.1 入口校验:/preview 路由

app/(app)/preview/route.ts/preview/route.ts) 是整个安全模型的关键,逻辑分五步:

export async function GET(req: NextRequest): Promise<Response> {
  const payload = await getPayload({ config: configPromise })

  const { searchParams } = new URL(req.url)
  const path = searchParams.get('path')
  const previewSecret = searchParams.get('previewSecret')

  // 1. 校验 secret 是否与后端 PREVIEW_SECRET 一致
  if (previewSecret !== process.env.PREVIEW_SECRET) {
    return new Response('You are not allowed to preview this page', { status: 403 })
  }

  // 2. 必须有 path
  if (!path) {
    return new Response('Insufficient search params', { status: 404 })
  }

  // 3. path 必须是站内相对路径,防止开放重定向
  if (!path.startsWith('/')) {
    return new Response('This endpoint can only be used for relative previews', { status: 500 })
  }

  // 4. 用 http-only cookie 中的 JWT 验证用户身份
  let user
  try {
    user = await payload.auth({
      req: req as unknown as PayloadRequest,
      headers: req.headers,
    })
  } catch (error) {
    payload.logger.error({ err: error }, 'Error verifying token for live preview')
    return new Response('You are not allowed to preview this page', { status: 403 })
  }

  if (!user) {
    draft.disable()
    return new Response('You are not allowed to preview this page', { status: 403 })
  }

  // 5. 校验通过,开启 Next.js Draft Mode 并重定向到目标页
  draft.enable()
  redirect(path)
}

其中 draftMode() 来自 next/headers。README 特别指出:"Preview mode" 的具体形态因框架而异。在 Next.js 中,Draft Mode 允许你在浏览器中设置 cookie,使内容按草稿展示;换到其他前端框架时(如 TanStack、Remix、Astro),这一段需要按各框架的机制自行实现,但"secret + 身份 cookie 双重校验"的思路可以复用。

4.2 退出预览:/exit-preview 路由

退出同样是一个 API 路由,调用 draftMode()disable() 清除 Draft Mode 的 cookie:

// src/app/(app)/exit-preview/route.ts
import { draftMode } from 'next/headers'

export async function GET(): Promise<Response> {
  const draft = await draftMode()
  draft.disable()
  return new Response('Draft mode is disabled')
}

4.3 预览态贯穿全局:AdminBar 的 preview 属性

根布局 app/(app)/layout.tsx/layout.tsx) 在每次渲染时读取 draftMode() 的状态,并把它传给自定义 AdminBar 组件:

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const { isEnabled } = await draftMode()

  return (
    <html lang="en">
      <body>
        <AdminBar
          adminBarProps={{
            preview: isEnabled,
          }}
        />
        <Header />
        {children}
      </body>
    </html>
  )
}

preview 为 true 时,AdminBar 上会出现退出预览的入口,方便编辑人员在"后台编辑 ↔ 前端预览"之间快速往返。

五、Admin Bar:后台与前端的快速通道

README 建议在前端渲染一条 admin bar,让登录中的用户在前端与 Payload 管理面板之间快速导航。示例的做法是:

  • React 应用直接使用官方 Payload Admin Bar 包(示例依赖中即有 @payloadcms/admin-bar),本示例在其基础上做了 自定义封装,并把 Draft Mode 状态通过 preview 属性透传进去;
  • 非 React 框架的替代方案:带 credentials: 'include' 请求 Payload 的 /me 路由,若返回已登录则自行渲染一条 admin bar。

六、On-demand Revalidation:发布即重新生成页面

如果前端是静态生成的,只靠 Draft Mode 预览还不够——页面发布后还需要"按需重新验证"(On-demand Revalidation):每次文档更新时单独重新生成对应页面的 HTML,避免为一次内容变更而整站重建。

README 给出的方案是:给集合添加 afterChange 钩子,在文档每次更新时向前端发一个后台请求,由前端处理该请求来 revalidate 对应页面的 HTML。示例中的实现是 hooks/revalidatePage.ts

import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath } from 'next/cache'

export const revalidatePage: CollectionAfterChangeHook<Page> = ({ doc, previousDoc, req }) => {
  if (req.context.skipRevalidate) {
    return doc
  }

  // 文档变为已发布:revalidate 新路径
  if (doc._status === 'published') {
    const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
    req.payload.logger.info(`Revalidating page at path: ${path}`)
    revalidatePath(path)
  }

  // 之前是已发布、现在不再是:revalidate 旧路径(让旧页面回到 404/重建)
  if (previousDoc?._status === 'published' && doc._status !== 'published') {
    const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
    req.payload.logger.info(`Revalidating old page at path: ${oldPath}`)
    revalidatePath(oldPath)
  }

  return doc
}

两个实现细节值得注意:

  • home slug 特判:首页在前端路由是 /,所以 revalidate 的路径做了 doc.slug === 'home' ? '/' : \/${doc.slug}`` 的映射;
  • req.context.skipRevalidate 逃生口:seed 脚本在创建/更新数据时通过 context: { skipRevalidate: true }(见 migrations/seed.ts)跳过 revalidation,避免初始化数据库时对空站做无意义的路径重建;全局文档 MainMenu 也有同款钩子 revalidateMainMenu.ts

同理,README 也提醒:按需重新验证的行为因框架而异(Next.js App Router 提供针对特定页面的 on-demand revalidation),移植到其他框架时需要对应变通。

七、CORS / CSRF / Cookies:跨域安全配置

本示例中前端与后台是同域同端口部署在 Next.js 中,但配置上仍按"前后端可能分离"的安全基线来写。payload.config.ts 中:

cors: [process.env.NEXT_PUBLIC_SERVER_URL || ''].filter(Boolean),
csrf: [process.env.NEXT_PUBLIC_SERVER_URL || ''].filter(Boolean),

corscsrfcookies 三项设置的目的是确保管理面板与前端之间能安全地跨域通信:CORS 限定允许的来源,CSRF 校验同源/可信来源,cookies 配置保证 http-only 会话 cookie 在跨域场景下可被正确携带。README 的说明是:如果你把前端和管理面板合并进同一个共享端口与域的应用,可以移除这些设置以简化配置。相关背景见仓库文档 CORS/CSRF 防护Cookie 配置

八、Seed:开箱即用的演示数据

示例内置 seed 脚本(migrations/seed.ts),在启动时为你搭好一个可直接体验的数据库:

  • 创建演示用户:demo@payloadcms.com / demo
  • 创建首页(home);
  • 创建一个 example-page,并故意造出两个版本:先用 published 数据创建(seed/page.ts),再以 draft: true 更新出一份草稿(seed/pageDraft.ts)——这正是 Draft Preview 演示所需的"一已发布一草稿"状态;
  • 同步初始化 main-menu 全局文档,把首页与示例页挂进导航。

README 给出的管理方式与注意事项:

  • dev 脚本中的 pnpm seed 会在每次启动前执行;不需要该行为可从 package.jsondev 脚本中移除;
  • 任意时刻手动执行 pnpm seed 可重新播种;
  • 注意:seed 是破坏性操作——它会 drop 当前数据库并从模板重新填充。只在启动新项目或可接受丢失现有数据时执行。

九、Production 构建与部署

生产环境运行需要构建并启动 Admin Panel(README 步骤):

  1. 在项目根目录执行 pnpm buildnpm run build,调用 next build,生成包含生产可用 admin bundle 的 .next 目录;
  2. 执行 pnpm startnpm run start,以生产模式运行 Node,从 .build 目录对外提供 Payload 服务。

部署方面,README 提到最省事的方式是使用 Payload Cloud 一键托管;自托管则参考仓库文档 Deployment。生产化前建议同时阅读 防止滥用(CORS/CSRF)Rotating Secret 相关文档,确保 PAYLOAD_SECRETPREVIEW_SECRET 等机密通过环境变量注入而非硬编码。

十、示例文件地图

文件 作用
payload.config.ts Payload 总配置:collections、cors、csrf、secret、db
collections/Pages/index.ts Pages 集合:drafts: trueadmin.preview、access、afterChange
access/publishedOrLoggedIn.ts 只读访问控制:未登录仅可见 published
hooks/revalidatePage.ts 发布后按需重新验证前端静态页
app/(app)/preview/route.ts/preview/route.ts) secret + JWT 双重校验,开启 Draft Mode
app/(app)/exit-preview/route.ts/exit-preview/route.ts) 关闭 Draft Mode
app/(app)/[slug]/page.tsx 按 Draft Mode 状态取草稿/已发布文档
app/(app)/layout.tsx/layout.tsx) 把预览态传给 AdminBar
migrations/seed.ts 播种演示用户与"一发布一草稿"的示例页
package.json dev(先 seed)、seedbuildstart 脚本

十一、小结

回到 README 的一句话定义:Draft Preview 让用户带着 secret 与 http-only cookie 跳进前端的 "draft mode",此后查询被改写为拉取草稿内容。本示例把这句话落成了四段可验证的代码:

  1. 数据层versions.drafts 提供 _status 与版本能力,publishedOrLoggedIn 保证草稿对公众不可见;
  2. 入口层admin.preview 生成带 pathpreviewSecret 的跳转 URL,/preview 路由完成 secret 比对、相对路径校验、payload.auth 身份验证三步后才 draft.enable()
  3. 渲染层:页面组件读取 draftMode() 决定 payload.finddraft / overrideAccess 参数,构建期静态参数只收录 published 文档;
  4. 发布层afterChange 钩子在状态切换到 published 时调用 revalidatePath,实现发布即更新静态页。

这四段各自独立、可单独移植,组合起来就构成了一套完整的内容预发布预览工作流;若要扩展更多字段或行为,可进一步参考仓库文档 Collections 配置DraftsAccess Control

登录后查看全文
热门项目推荐
相关项目推荐