内容分阶段发布:开放基础设施与供应商锁定

如果您曾经推送过一个营销网站重新设计,需要在午夜时分精确上线47个页面,您就会知道内容分阶段发布不是"锦上添花"的功能。这是干净发布和晚上11:58分疯狂Slack消息之间的区别。但这里有个问题——大多数提供内容分阶段和定时发布的CMS平台都附带代价。很大、很昂贵、会形成锁定的代价。

在过去两年里,我在Social Animal为客户构建内容管道时,使用了无头CMS平台、开源工具和自定义分阶段工作流程的组合。我学到的是,您不需要将整个内容操作交给单一供应商来获得专业级的内容发布。您可以用开放基础设施构建更好的东西。

本文详细分析了供应商管理的内容分阶段(如Sanity的Content Releases)与使用Supabase功能标志进行自主构建之间的真实权衡,然后向您展示如何结合两种方法的优势。

目录

2025年内容分阶段实际含义

内容分阶段已经超越了"发布前预览"。在现代无头架构中,内容分阶段意味着跨多个内容源编排更改、确保预览环境中的视觉一致性,以及原子地发布内容批次——意味着所有内容同时上线或都不上线。

以下是Social Animal通过我们的无头CMS开发实践为我们构建的网站涉及的典型内容发布内容:

  • 多个文档更改:10-50个需要同时发布的内容文档
  • 交叉引用完整性:引用新类别的新页面引用新作者
  • 预览环境:编辑人员需要在发布前看到分阶段内容的确切样子
  • 定时发布:内容在特定时间上线,通常与营销活动相关
  • 回滚能力:如果出现问题,您需要撤销整个发布,而不是单个部分

旧的WordPress方法是将每篇文章设置为"草稿",然后批量发布。这适用于博客文章。当您跨登陆页面、文档、价格表和功能对比协调产品发布时,它会完全失败。

内容分阶段的三个级别

并不是每个项目都需要相同级别的复杂性:

第1级:每个文档的草稿/发布。 每个CMS都有这个。这对内容独立的编辑工作流程很好。

第2级:分组发布。 多个文档一起分阶段并原子地发布。这是Sanity Content Releases和类似功能提供的。

第3级:基于环境的分阶段。 具有功能标志控制哪个内容版本处于活跃状态的完整预览环境。这是开放基础设施真正闪耀的地方。

大多数团队认为他们需要第2级,但实际上需要第3级。原因如下:第2级单独处理内容更改,但真实的发布涉及代码更改、设计更改和内容更改同时进行。第3级允许您协调所有三个。

内容发布的供应商锁定问题

让我直率地说一些话。当CMS供应商将内容分阶段构建到他们的平台中时,他们这样做并不是出于善意。这是一条护城河。一旦您的编辑团队依赖供应商特定的发布管理,切换CMS平台意味着从头开始重建整个工作流程。

这体现在几个方面:

定价杠杆。 内容发布几乎总是溢价功能。Sanity在其Growth计划后面隐藏它们。Contentful将它们放在Premium层中。一旦您的团队依赖它们,供应商知道当他们提价时您无处可去。

工作流程耦合。 您的编辑人员学习供应商特定的UI和心智模型。您的开发人员针对供应商特定的API编写发布管理集成。您的CI/CD管道具有供应商特定的webhooks。解开所有这些需要3-6个月的项目。

定制有限。 供应商实现为您做出决定。如果您需要跨越两个不同CMS实例的发布怎么办?如果您需要将内容发布与LaunchDarkly中的功能标志推出相关联怎么办?如果您需要不符合供应商想象的审批工作流程怎么办?

我不是说供应商管理的发布总是错误的。对于有简单需求的小团队,它们可能是正确的选择。但您应该清楚地了解您放弃了什么。

Sanity Content Releases:您获得什么以及成本是多少

Sanity引入了Content Releases(在早期迭代中之前称为"Spaces")作为一种将文档更改分组为可以一起发布的命名发布的方法。让我们公平地说它做得很好。

Sanity Content Releases实际做什么

  • 创建命名的"发布",包含对多个文档的草稿更改
  • 在发布前在上下文中预览所有更改
  • 使用单个操作原子地发布所有更改
  • 安排发布以供将来发布
  • 查看发布历史记录并(在某些情况下)恢复

开发人员体验是扎实的。您可以使用Sanity的perspective参数从特定发布perspective查询内容:

// 从Sanity中的特定发布查询内容
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'your-project',
  dataset: 'production',
  apiVersion: '2025-01-01',
  useCdn: false,
})

// 获取文档,如它们在'summer-launch'发布中出现的方式
const results = await client.fetch(
  `*[_type == "landingPage"]`,
  {},
  { perspective: 'release.summer-launch' }
)

成本现实

截至2025年年中,Sanity的Content Releases定价至少需要Growth计划:

  • 免费计划:无内容发布
  • Growth计划:$15/用户/月——包括基本内容发布
  • 企业版:自定义定价——包括高级调度、审批工作流程

对于8位编辑的团队,您至少每年看$1,440,只是为了获得分组发布。这还没有算上您的分阶段工作流程生成的额外预览查询的API超额费用。

这很贵吗?本质上不是。但这是一项随团队规模扩展并使您更深入地锁定到Sanity生态系统的经常性成本。

使用Supabase构建内容功能标志

这是事情变得有趣的地方。Supabase——开源Firebase替代品——为您提供了构建内容分阶段系统的原语,可以与供应商解决方案相媲美。由于它是开放基础设施(您可以自托管),没有锁定。

核心思想:使用Supabase作为坐在您的CMS和前端之间的内容功能标志系统。内容以其最终形式存在于您的CMS中,但Supabase控制哪个版本的内容是可见的。

为什么将Supabase用于此

  • 行级安全性(RLS):您可以创建策略,根据用户上下文(预览 vs. 生产)控制哪些内容版本可见
  • 实时订阅:编辑可以立即在预览环境中看到分阶段更改反映
  • Edge Functions:部署自定义发布逻辑靠近您的用户
  • 自托管:如果您曾经需要离开Supabase Cloud,您可以自己运行整个堆栈
  • 底下是PostgreSQL:您的分阶段元数据存在于真实数据库中,而不是专有系统中

基本架构

-- Supabase中的内容发布管理
CREATE TABLE content_releases (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name TEXT NOT NULL,
  status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'staged', 'published', 'rolled_back')),
  scheduled_at TIMESTAMPTZ,
  published_at TIMESTAMPTZ,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE release_items (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  release_id UUID REFERENCES content_releases(id) ON DELETE CASCADE,
  cms_document_id TEXT NOT NULL,  -- 引用Sanity/Contentful/等等
  cms_type TEXT NOT NULL,
  content_snapshot JSONB,  -- 分阶段时内容的快照
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE feature_flags (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  key TEXT UNIQUE NOT NULL,
  enabled BOOLEAN DEFAULT false,
  release_id UUID REFERENCES content_releases(id),
  metadata JSONB DEFAULT '{}',
  updated_at TIMESTAMPTZ DEFAULT now()
);

这为您提供了完全独立于您的CMS供应商的发布管理系统。明年用Contentful替换Sanity?您的发布管理不会改变。

正面对比:Supabase功能标志 vs Sanity Content Releases

让我们诚实地比较这些方法:

因素 Sanity Content Releases Supabase功能标志
设置时间 几分钟(内置) 几天(自定义构建)
每月成本(8位编辑) ~$120/月(Growth计划) ~$25/月(Supabase Pro)
供应商锁定 高(Sanity特定) 低(PostgreSQL +开源)
预览体验 优秀(本地Studio) 良好(需要自定义预览)
跨CMS发布 否(仅Sanity) 是(CMS无关)
代码+内容发布 是(与部署标志相关)
调度 内置(Growth+) 自定义(Edge Functions + cron)
回滚 部分 完整(您控制逻辑)
编辑UX 抛光 取决于您的实现
自托管
审批工作流程 仅企业版 自定义(构建您需要的)

权衡是明确的:Sanity为您提供抛光、现成的体验。Supabase为您提供灵活性和独立性,但您需要自己构建编辑界面。

架构:开放基础设施内容分阶段管道

这是我们为需要认真内容分阶段而无供应商依赖的客户确定的架构。我们在我们的Next.js开发项目Astro构建中频繁使用这种模式。

流程

  1. 内容编写发生在任何无头CMS中(Sanity、Contentful、Strapi——无关紧要)
  2. CMS webhooks在内容更改时触发,将元数据推送到Supabase
  3. Supabase存储发布组——哪些内容更改属于哪个发布
  4. 预览环境查询Supabase以确定要显示哪个内容版本
  5. 发布触发器(手动、定时或API驱动)在Supabase中翻转功能标志
  6. ISR/按需重新验证在Next.js或Astro中重建受影响的页面
  7. 回滚恢复功能标志并触发另一个重新验证

关键洞察

您的CMS不需要了解发布。内容只是作为CMS中的已发布状态或草稿状态存在。Supabase充当流量管制员,决定您的前端呈现哪个内容版本。

这种解耦非常强大。它意味着您可以:

  • 使用Sanity的免费层并仍然获得内容发布
  • 跨多个CMS实例协调发布
  • 通过相同的功能标志系统将内容发布与代码部署相关联
  • 切换CMS供应商而无需重建您的发布管道

实现指南

让我们构建这个系统的核心。我将使用Next.js作为前端框架,因为这是我们大多数客户使用的,但这种模式适用于任何框架。

步骤1:Supabase发布经理

// lib/releases.ts
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
)

export async function createRelease(name: string) {
  const { data, error } = await supabase
    .from('content_releases')
    .insert({ name, status: 'draft' })
    .select()
    .single()

  if (error) throw error
  return data
}

export async function addToRelease(
  releaseId: string,
  cmsDocumentId: string,
  cmsType: string,
  contentSnapshot: Record<string, unknown>
) {
  const { error } = await supabase
    .from('release_items')
    .insert({
      release_id: releaseId,
      cms_document_id: cmsDocumentId,
      cms_type: cmsType,
      content_snapshot: contentSnapshot,
    })

  if (error) throw error
}

export async function publishRelease(releaseId: string) {
  // 原子发布:更新发布状态并启用功能标志
  const { error: releaseError } = await supabase
    .from('content_releases')
    .update({ status: 'published', published_at: new Date().toISOString() })
    .eq('id', releaseId)

  if (releaseError) throw releaseError

  const { error: flagError } = await supabase
    .from('feature_flags')
    .update({ enabled: true, updated_at: new Date().toISOString() })
    .eq('release_id', releaseId)

  if (flagError) throw flagError

  // 触发受影响页面的重新验证
  await triggerRevalidation(releaseId)
}

步骤2:内容解析中间件

这是魔法发生的地方。您的数据获取层检查Supabase功能标志以确定要提供哪个内容版本:

// lib/content-resolver.ts
import { createClient as createSanityClient } from '@sanity/client'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'

const sanity = createSanityClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  apiVersion: '2025-01-01',
  useCdn: true,
})

const supabase = createSupabaseClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
)

export async function resolveContent(
  documentId: string,
  isPreview: boolean = false,
  previewReleaseId?: string
) {
  // 检查是否有包含此文档的活跃发布
  let releaseContent = null

  if (isPreview && previewReleaseId) {
    // 在预览模式中,显示特定发布中的分阶段内容
    const { data } = await supabase
      .from('release_items')
      .select('content_snapshot')
      .eq('release_id', previewReleaseId)
      .eq('cms_document_id', documentId)
      .single()

    releaseContent = data?.content_snapshot
  } else {
    // 在生产中,检查任何已发布的发布是否覆盖此文档
    const { data } = await supabase
      .from('release_items')
      .select('content_snapshot, content_releases!inner(status)')
      .eq('cms_document_id', documentId)
      .eq('content_releases.status', 'published')
      .order('created_at', { ascending: false })
      .limit(1)
      .single()

    releaseContent = data?.content_snapshot
  }

  if (releaseContent) {
    return releaseContent
  }

  // 回退到标准CMS内容
  return sanity.fetch(`*[_id == $id][0]`, { id: documentId })
}

步骤3:使用Supabase Edge Functions的定时发布

// supabase/functions/publish-scheduled/index.ts
import { createClient } from '@supabase/supabase-js'

Deno.serve(async () => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  // 查找定时为现在或更早且尚未发布的发布
  const { data: dueReleases } = await supabase
    .from('content_releases')
    .select('id, name')
    .eq('status', 'staged')
    .lte('scheduled_at', new Date().toISOString())

  if (!dueReleases?.length) {
    return new Response(JSON.stringify({ published: 0 }), {
      headers: { 'Content-Type': 'application/json' },
    })
  }

  for (const release of dueReleases) {
    await supabase
      .from('content_releases')
      .update({ status: 'published', published_at: new Date().toISOString() })
      .eq('id', release.id)

    await supabase
      .from('feature_flags')
      .update({ enabled: true })
      .eq('release_id', release.id)

    console.log(`Published release: ${release.name}`)
  }

  return new Response(
    JSON.stringify({ published: dueReleases.length }),
    { headers: { 'Content-Type': 'application/json' } }
  )
})

将其设置为每分钟运行一次的Supabase cron作业,您就有了定时发布。

步骤4:预览路由

在Next.js App Router中:

// app/api/preview/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 releaseId = searchParams.get('release')
  const slug = searchParams.get('slug') || '/'

  if (!releaseId) {
    return new Response('Missing release parameter', { status: 400 })
  }

  const draft = await draftMode()
  draft.enable()

  // 为内容解析器在cookie中存储发布ID
  const response = redirect(slug)
  response.headers.set(
    'Set-Cookie',
    `preview-release=${releaseId}; Path=/; HttpOnly; SameSite=Lax`
  )

  return response
}

现在编辑可以通过访问/api/preview?release=summer-launch&slug=/products来预览任何发布。他们会看到该发布上线时网站的确切样子。

何时使用哪种方法

我不相信一刀切的答案。这是我的诚实建议:

在以下情况下使用Sanity Content Releases:

  • 您的团队很小(少于5位编辑)
  • 您已经长期致力于Sanity
  • 内容发布很简单(无需跨系统协调)
  • 您没有开发人员带宽来构建自定义工具
  • 预算不是主要问题

在以下情况下使用Supabase功能标志进行构建:

  • 您需要协调内容+代码发布
  • 您使用多个CMS平台或计划在将来进行切换
  • 您的团队有供应商工具不支持的特定工作流程要求
  • 您想自托管您的发布管理基础设施
  • 长期成本和独立性比设置速度更重要

在以下情况下使用混合方法:

  • 您想要Sanity的编辑体验,但需要跨系统的发布协调
  • 您在CMS平台之间迁移,需要幸存过渡的发布管理
  • 您需要影响内容和应用行为的细粒度功能标志

混合方法实际上是我们在无头CMS参与中最经常推荐的。对单个文档使用您CMS的本地草稿/发布,并在顶部分层Supabase进行协调的发布和功能标志。

常见问题

无头CMS中的内容分阶段到底是什么? 内容分阶段是在内容上线前准备、预览和分组内容更改的过程。在无头架构中,这意味着跨多个API驱动的内容源管理草稿内容并确保预览环境准确反映已发布内容的外观。它超越了简单的草稿/发布切换——真实的分阶段涉及将相关更改分组为原子发布的发布。

Supabase真的足够便宜来替换付费CMS功能吗? Supabase的免费层为您提供500MB的数据库存储、50,000个月活跃用户和500,000个Edge Function调用。对于内容分阶段元数据(这只是发布组和功能标志——不是内容本身),这对大多数团队来说已足够。Pro计划$25/月涵盖更大的操作。与按座位支付CMS溢价功能相比,对于超过3-4位编辑的团队,数学会很快计算。

我可以将此方法与Contentful、Strapi或其他CMS平台一起使用吗? 绝对可以。这就是全部要点。由于Supabase坐在您的CMS和前端之间作为独立层,它不关心您的内容来自何处。我们已经用Sanity、Contentful、Hygraph甚至WordPress作为内容源实现了这种模式。内容解析器中间件只需要知道如何从您的特定CMS获取。

我如何使用功能标志方法处理回滚? 回滚实际上比供应商管理的发布更简单,使用功能标志。您将功能标志翻转回禁用,触发受影响页面的重新验证,就完成了。存储在Supabase中的内容快照用作您的回滚点。相反,供应商管理的回滚通常需要单独重新发布之前的文档版本。

在内容分阶段期间实时协作怎么样? 这是供应商工具真正闪耀的地方。Sanity在Studio中的实时协作是最好的——多个编辑可以同时处理分阶段内容并看到彼此的更改。如果您构建自己的分阶段层,您可以对某些使用Supabase Realtime,但如果没有大量投资,您将无法匹配本地CMS协作功能的抛光。

这适用于静态网站生成器和ISR吗? 是的,它特别有效。对于带有ISR(增量静态再生)的Next.js或Astro的混合渲染,当发布发布时触发按需重新验证。重新验证API调用您的内容解析器,现在返回新发布的内容,受影响的页面得到重新生成。我们已经为使用我们Next.js开发服务Astro开发服务的客户详细记录了我们的方法。

我如何为管理发布构建编辑UI? 您有几种选择。最快的是使用Supabase自动生成的REST API和React组件库(如Shadcn/UI)构建一个简单的管理面板。它需要2-3天来构建一个可用的东西。为了更多抛光,您可以使用与Supabase发布管理API对话的自定义插件扩展Sanity Studio。我们还看到团队使用Retool或类似的内部工具构建器在几小时内创建发布仪表板。

构建自己的内容分阶段系统的风险是什么? 最大的风险是维护负担。供应商管理的功能自动获得错误修复、性能改进和新功能。当您自己构建时,您拥有所有这些。第二个风险是边缘情况——比如两个发布修改同一文档时的冲突解决,或处理依赖特定代码更改的发布。这些是可解决的问题,但它们需要周密的工程。如果您的团队没有能力做到这一点,供应商解决方案会更安全。如果您想帮助设计适合您团队需求的自定义分阶段管道,您可以与我们联系

大规模定价比较如何——比如20多位编辑? Sanity Growth上的20位编辑($15/用户/月),您仅在包括内容发布的计划中支付$3,600/年。Supabase Pro每月$25($300/年)加上Edge Functions额外计算和额外数据库使用的大约$50/月,您每年总共约$900。差距在企业规模时会进一步扩大。但请记住计算自定义系统构建和维护的开发成本——通常前期40-80小时,每月持续2-4小时。有关您特定情况的成本明细,请查看我们的定价页面