內容分段和發佈:供應商托管與開源基礎設施的對比

如果你曾經推出過行銷網站重新設計,需要在午夜準時讓 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 管道有供應商特定的 webhook。解除所有這些的束縛是一個 3-6 個月的專案。

有限的自訂。 供應商實施為你做出決定。如果你需要跨越兩個不同的 CMS 實例的發佈怎麼辦?如果你需要將內容發佈與 LaunchDarkly 中的功能標誌推出相關聯怎麼辦?如果你需要不符合供應商想象的批准工作流怎麼辦?

我不是說供應商管理的發佈總是錯誤的。對於有簡單需求的小團隊,他們可能是正確的選擇。但你應該眼睛睜得很開地了解你正在交換什麼。

Sanity Content Releases:你能得到什麼以及成本

Sanity 引入了 Content Releases(在早期迭代中曾稱為「Spaces」)作為一種將文件變更分組為可以一起發佈的命名發佈的方式。讓我們公平地說它做得很好的地方。

Sanity Content Releases 實際做什麼

  • 建立包含多個文件的草稿變更的命名「發佈」
  • 發佈前在上下文中預覽所有變更
  • 用單一操作原子發佈所有變更
  • 排定未來發佈時間
  • 查看發佈歷史記錄,(在某些情況下)回滾

開發人員體驗是紮實的。你使用 Sanity 的 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):你可以建立策略,根據使用者背景(預覽與生產)控制哪些內容版本可見
  • 即時訂閱:編輯人員可以在預覽環境中立即看到分段變更
  • 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 供應商的發佈管理系統。明年將 Sanity 換為 Contentful?你的發佈管理不會改變。

正面對比:Supabase 功能標誌對比 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 webhook 在內容變更時觸發,將中繼資料推送到 Supabase
  3. **Supabase 存儲發佈組——哪些內容變更屬於哪個發佈
  4. 預覽環境查詢 Supabase 以確定要顯示哪個內容版本
  5. 發佈觸發(手動、排定或 API 驅動)翻轉 Supabase 中的功能標誌
  6. Next.js 或 Astro 中的 ISR/按需重新驗證重新構建受影響的頁面
  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 小時。有關你特定情況成本的詳細信息,請查看我們的定價頁面