我們在超過三年的時間內,在多個客戶專案中以 Sanity 作為主要 CMS 運行。當文章數量達到 3,000 篇左右時,你就不再思考 Sanity 文檔上說了什麼,而是開始思考什麼能真正在生產環境中存活。這篇文章就是那份思維轉儲 -- 我們後悔的每一個 schema 決策、每一個導致構建失敗的 GROQ 查詢,以及每一個使編輯們真正想要使用 CMS 而不是給我們寄送 Word 檔案的 Studio 自訂設定。

這不是一份入門指南。如果你在這裡,你可能已經設置了 Sanity Studio,建立了幾個 schema,也許還上線了一個或兩個網站。我想分享的是只有在你處理了真實內容團隊、真實編輯工作流程和真實大規模性能預算後才會出現的模式。

目錄

Sanity Studio 生產經驗:來自 3000+ 文章的課程

能承受真實內容團隊的 Schema 設計

Schema 設計是大多數 Sanity 專案以低調方式失敗的地方。不是以戲劇性的崩潰方式 -- 更像是編輯信心的緩慢侵蝕。內容團隊開始避免使用某些欄位。他們創建變通方法。六個月後,一半的結構化內容實際上被塞進了單個富文本塊中,因為 schema 「太複雜」。

停止過度嵌套物件

我們最大的早期錯誤是建立深度嵌套的物件結構。我們會像資料庫 schema 一樣來建模內容 -- 規範化、優雅、技術上正確。一篇部落格文章有一個 author 引用,它有一個 bio 物件,它有一個 socialLinks 物件陣列,每個都有一個 platform 引用。

編輯們討厭它。每次他們需要更新作者的 Twitter 代碼時,他們都要點擊五次。以下是我們現在的做法:

// 之前:過度設計
export default defineType({
  name: 'author',
  type: 'document',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
    }),
    defineField({
      name: 'bio',
      type: 'object',
      fields: [
        defineField({
          name: 'content',
          type: 'array',
          of: [{ type: 'block' }],
        }),
        defineField({
          name: 'socialLinks',
          type: 'array',
          of: [
            defineArrayMember({
              type: 'object',
              fields: [
                { name: 'platform', type: 'reference', to: [{ type: 'platform' }] },
                { name: 'url', type: 'url' },
              ],
            }),
          ],
        }),
      ],
    }),
  ],
})

// 之後:平坦、編輯友善
export default defineType({
  name: 'author',
  type: 'document',
  fields: [
    defineField({ name: 'name', type: 'string', validation: (r) => r.required() }),
    defineField({ name: 'bio', type: 'array', of: [{ type: 'block' }] }),
    defineField({ name: 'twitter', type: 'url', title: 'Twitter / X URL' }),
    defineField({ name: 'linkedin', type: 'url', title: 'LinkedIn URL' }),
    defineField({ name: 'github', type: 'url', title: 'GitHub URL' }),
  ],
})

是的,平坦版本在「純淨度」上較差。但它也能被正確使用 100% 的時間。取捨已接受。

積極使用欄位群組

一旦文檔類型有超過 8-10 個欄位,編輯就開始滾動並遺漏內容。Sanity v3 的欄位群組被嚴重低估了。我們在每個擁有超過六個欄位的文檔類型上都使用它們:

export default defineType({
  name: 'post',
  type: 'document',
  groups: [
    { name: 'content', title: '內容', default: true },
    { name: 'seo', title: 'SEO' },
    { name: 'settings', title: '設定' },
  ],
  fields: [
    defineField({ name: 'title', type: 'string', group: 'content' }),
    defineField({ name: 'body', type: 'array', of: [{ type: 'block' }], group: 'content' }),
    defineField({ name: 'seoTitle', type: 'string', group: 'seo' }),
    defineField({ name: 'seoDescription', type: 'text', rows: 3, group: 'seo' }),
    defineField({ name: 'publishDate', type: 'datetime', group: 'settings' }),
    defineField({ name: 'featured', type: 'boolean', group: 'settings' }),
  ],
})

引導而非限制的驗證

我們學會了將驗證視為 UX,而非強制執行。在每個欄位上硬性 required() 驗證意味著編輯無法保存草稿。說明為什麼某事重要的自訂驗證訊息比通用錯誤狀態獲得更好的遵守率:

defineField({
  name: 'excerpt',
  type: 'text',
  rows: 3,
  validation: (rule) =>
    rule
      .max(160)
      .warning('超過 160 個字符的摘要在搜索結果和社交卡中會被截斷。'),
})

注意那是一個 warning,而不是 error。編輯仍然可以發布。他們只是知道後果。

大規模 GROQ 性能:什麼真正重要

GROQ 直到某個時刻都很棒。在 500 個文檔時,所有內容都很快。在 3,000+ 個帶有引用、圖像和可攜帶文本的文檔時,你開始注意到一些事情。

投影不是可選的

單一最大的 GROQ 性能杠杆是投影。停止在只需要三個欄位時獲取整個文檔。我見過 Next.js 構建通過修復 generateStaticParams 呼叫中的 GROQ 投影,從 4 分鐘縮短到 90 秒。

// 慢:獲取所有內容,包括可攜帶文本、圖像、引用
*[_type == "post"]

// 快:只有列表頁面實際需要的內容
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

那個 author->name 內聯解引用是關鍵。它避免獲取整個作者文檔。當你有 3,000 篇文章各引用 50 個作者中的一個時,區別是可測量的。

沒有人談論的連接問題

Sanity 的 GROQ 文檔顯示解引用就像它是免費的一樣。它不是。查詢中的每個 -> 本質上都是一個連接。在返回 100 個結果的列表查詢中堆積三或四個並且你會感受到它。

我們現在在我們的專案中對每個 GROQ 查詢進行分析。以下是我們的經驗法則:

模式 文檔 平均回應時間
簡單獲取,無參考 3,000 ~120ms
一層 -> 解引用 3,000 ~250ms
兩層 -> 3,000 ~600ms
內部帶有 -> 的嵌套陣列 3,000 ~1,200ms+

這些是來自我們 Sanity API 儀表板在 2025 年中期的實際數字。你的結果可能會根據文檔大小而有所不同,但趨勢是一致的。

我們不斷使用的 GROQ 模式

預覽與已發布的條件獲取:

*[_type == "post" && slug.current == $slug && ($preview || !(_id in path('drafts.**')))] [0] {
  ...,
  "author": author-> { name, slug, image },
  "categories": categories[]-> { title, slug }
}

帶計數的分頁查詢:

{
  "posts": *[_type == "post"] | order(publishedAt desc) [$start...$end] {
    _id, title, slug, publishedAt,
    "authorName": author->name
  },
  "total": count(*[_type == "post"])
}

沒有 N+1 的相關文章:

*[_type == "post" && slug.current == $slug][0] {
  ...,
  "related": *[_type == "post" && _id != ^._id && count(categories[@._ref in ^.^.categories[]._ref]) > 0] | order(publishedAt desc) [0...3] {
    title, slug, publishedAt
  }
}

那個相關文章查詢很密集,但它在 Sanity 的基礎設施中伺服器端運行,所以它通常比進行兩次往返更快。

值得投資的 Studio 自訂設定

原生 Sanity Studio 對開發人員來說很好。對於每週發布 20 篇文章的內容團隊來說,它不好。以下是我們在每個專案上自訂的內容。

自訂文檔操作

默認發布操作在每個設置中都不能可靠地觸發 webhook。我們包裝它:

import { useDocumentOperation } from 'sanity'

export function createPublishWithWebhookAction(originalPublishAction) {
  return function PublishWithWebhook(props) {
    const originalResult = originalPublishAction(props)
    return {
      ...originalResult,
      onHandle: async () => {
        await originalResult.onHandle()
        // 觸發 ISR 重新驗證或部署掛鉤
        await fetch('/api/revalidate', {
          method: 'POST',
          body: JSON.stringify({ type: props.type, id: props.id }),
        })
      },
    }
  }
}

用於編輯工作流程的結構生成器

默認的辦公桌結構在平面列表中顯示每個文檔類型。在 15+ 種文檔類型中,這是混亂的。我們使用結構生成器來建立以編輯為中心的導航:

import { StructureBuilder } from 'sanity/structure'

export const structure = (S: StructureBuilder) =>
  S.list()
    .title('內容')
    .items([
      S.listItem()
        .title('部落格')
        .child(
          S.list()
            .title('部落格')
            .items([
              S.listItem()
                .title('已發布的文章')
                .child(
                  S.documentList()
                    .title('已發布')
                    .filter('_type == "post" && !(_id in path("drafts.**"))')
                ),
              S.listItem()
                .title('草稿')
                .child(
                  S.documentList()
                    .title('草稿')
                    .filter('_type == "post" && _id in path("drafts.**")')
                ),
              S.listItem()
                .title('所有文章')
                .child(S.documentTypeList('post').title('所有文章')),
            ])
        ),
      S.divider(),
      // ... 其他內容類型
    ])

這需要 30 分鐘來設置,但每週為編輯節省數小時的困惑。

可攜帶文本自訂元件

一件狠狠咬住我們的事情:編輯們將內容從 Google Docs 粘貼到可攜帶文本編輯器中。默認塊編輯器處理得還可以,但自訂塊類型需要顯式序列化器,否則它們顯示為空框並且編輯們會慌張。

我們為每個塊類型註冊自訂元件:

defineArrayMember({
  type: 'object',
  name: 'codeBlock',
  title: '代碼塊',
  fields: [
    defineField({ name: 'code', type: 'text' }),
    defineField({ name: 'language', type: 'string',
      options: { list: ['javascript', 'typescript', 'python', 'bash', 'groq'] }
    }),
  ],
  preview: {
    select: { code: 'code', language: 'language' },
    prepare({ code, language }) {
      return {
        title: `代碼 (${language || 'plain'})`,
        subtitle: code?.slice(0, 80) + '...',
      }
    },
  },
})

那個 preview 配置很小但非常重要。沒有它,編輯們看到空白塊並且不知道它們是什麼。

Sanity Studio 生產經驗:來自 3000+ 文章的課程 - 架構

內容遷移和數據完整性

我們已經進行了五次主要內容遷移到 Sanity -- 來自 WordPress、Contentful、Prismic、markdown 檔案和一個自訂 Rails CMS。每一次都教了我們一些痛苦的功課。

使用遷移工具,但相信並驗證

Sanity 的 @sanity/migrate 包和 CLI 的 sanity documents import 適用於直接的情況。對於涉及可攜帶文本轉換的任何內容,編寫自訂指令。始終。

# 在任何遷移之前導出所有內容以進行備份
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz

我們在每次遷移、每次 schema 部署之前運行此命令,誠實地說,每週一早上都通過 cron 運行。數據集很便宜。丟失的內容則不是。

Schema 版本控制策略

Sanity 不在數據層強制執行 schema 版本。這既是一個功能也是一個潛在問題。舊文檔在你更改 schema 時不會自動更新。我們使用一個簡單的模式:

defineField({
  name: 'schemaVersion',
  type: 'number',
  hidden: true,
  initialValue: 2,
  readOnly: true,
})

然後在遷移指令中,我們可以查詢 *[_type == "post" && schemaVersion < 2] 並批量更新文檔到新格式。它很粗糙但它有效。

部署和環境策略

Sanity 的數據集模型支持多個環境,你應該從第一天開始使用它 -- 而不是在你的第一次生產數據事件之後。

我們的標準設置

環境 數據集 Studio URL 用途
生產 production studio.client.com 即時內容編輯
暫存 staging staging-studio.client.com 內容 QA、schema 測試
開發 development localhost:3333 Schema 開發

我們每週使用 sanity dataset copy production staging 克隆生產到暫存。這保持暫存現實而不會在 schema 實驗期間冒著生產數據的風險。

對於前端,我們的 Next.js 開發 專案使用環境變數來切換數據集:

const config = {
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  apiVersion: '2025-01-01',
  useCdn: process.env.NODE_ENV === 'production',
}

CDN 與無 CDN

Sanity 的 API CDN 最終是一致的。對於市場營銷網站上的已發布內容,這很好 -- CDN 很快,陳舊窗口通常在 2 秒以下。對於預覽/草稿內容,始終繞過 CDN:

const client = sanityClient.withConfig({
  useCdn: false,
  token: process.env.SANITY_PREVIEW_TOKEN,
  perspective: 'previewDrafts',
})

我們見過預覽問題花費數小時來調試,只是為了意識到預覽客戶端正在點擊 CDN 並顯示陳舊數據。為所有預覽和草稿讀取上下文設置 useCdn: false

生產環境中的監控和調試

GROQ 查詢分析

Sanity 的管理控制台 (manage.sanity.io) 顯示 API 使用指標,但粒度並不總是足夠。我們在前端側記錄慢查詢:

async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
  const start = performance.now()
  const result = await client.fetch<T>(query, params)
  const duration = performance.now() - start

  if (duration > 500) {
    console.warn(`慢 GROQ 查詢 (${duration.toFixed(0)}ms):`, query.slice(0, 200))
  }

  return result
}

任何在生產環境中超過 500ms 的內容都會被調查。通常是一個未投影的查詢或一個嵌套的解引用,該引用在代碼審查中溜走了。

Webhook 可靠性

Sanity webhook 是可靠的但並非完美無缺。我們在 Sanity 基礎設施更新期間見過偶爾的遺漏 webhook。對於關鍵工作流程(如在我們的 Astro 開發 專案上觸發重建),我們實現了一個輪詢備用方案:

// 每 5 分鐘檢查一次最近的更改作為安全網
const POLL_INTERVAL = 5 * 60 * 1000

setInterval(async () => {
  const lastModified = await client.fetch(
    `*[_type == "post"] | order(_updatedAt desc) [0]._updatedAt`
  )
  if (new Date(lastModified) > lastKnownUpdate) {
    await triggerRebuild()
    lastKnownUpdate = new Date(lastModified)
  }
}, POLL_INTERVAL)

來自真實專案的性能基準

以下是我們在 2024-2025 年期間使用帶有無頭前端的 Sanity 運送的三個生產專案的實際數字:

指標 專案 A (Next.js) 專案 B (Astro) 專案 C (Next.js)
總文檔 3,200 1,800 4,100
文檔類型 12 8 18
平均 GROQ 回應 (CDN) 85ms 72ms 130ms
平均 GROQ 回應 (無 CDN) 180ms 145ms 290ms
完整靜態構建時間 3m 20s 1m 45s 6m 10s
ISR 重新驗證 1.2s N/A (靜態) 1.8s
月度 API 請求 ~450K ~180K ~1.2M
Sanity 計畫成本/月 成長 ($99) 免費 成長 ($99)

專案 C 的較長構建時間完全是由於圖像處理,而不是 GROQ。一旦我們轉移到 Sanity 的圖像管道並使用 @sanity/image-url 和適當的 width/height 參數,構建就停止下載全分辨率圖像。

對於 無頭 CMS 開發 專案,Sanity 的定價很有競爭力。免費層對較小的網站來說是真正可用的。每月 99 美元的成長計畫涵蓋大多數中型編輯操作。你只有在 API 請求量非常高時才開始遇到成本問題,即便如此,激進的 CDN 使用和智能緩存也保持合理。

Sanity 何時不是正確選擇

如果我不提及我們建議客戶遠離 Sanity 的情況,那我對你會做得不夠:

  • 高度關聯的數據(具有複雜變體關係的產品目錄)-- 一個專用商務平台甚至 Postgres 更有意義
  • 非常不技術性的團隊,需要 WYSIWYG 頁面生成器 -- Sanity 的可攜帶文本很強大但它不是 Squarespace
  • 預算受限的專案,超過 200K 月度 API 請求 -- 成本可能會令人驚訝

對於其他一切 -- 特別是編輯內容、市場營銷網站和文檔 -- Sanity 一直是我們首選的 CMS。如果你正在評估無頭專案的選項,聯繫我們,我們會根據你的特定需求給你一個誠實的評估。

常見問題

Sanity 在性能下降之前能處理多少文檔? 我們運行了超過 4,000 個文檔的生產專案,沒有有意義的性能下降。Sanity 的託管基礎設施處理數十萬個文檔量的文檔計數很好。性能瓶頸幾乎總是在你如何編寫 GROQ 查詢中 -- 特別是,未投影的獲取和深層引用鏈 -- 而不是原始文檔計數。

我應該使用 GROQ 還是 GraphQL 與 Sanity? GROQ,除非你有非常特定的理由使用 GraphQL。GROQ 對 Sanity 的文檔模型更具表達力,更自然地支持投影,並獲得 Sanity 團隊的一流關注。GraphQL API 是從你的 schema 自動生成的,工作得很好,但你失去了使 Sanity 強大的一些查詢靈活性。

你如何使用 Sanity 和 Next.js 處理草稿預覽? 我們使用 Next.js 草稿模式結合 Sanity 的 perspective: 'previewDrafts' 設定。預覽客戶端繞過 CDN 並使用讀取令牌。Sanity 的 @sanity/preview-kit 包提供實時監聽器,在編輯打字時更新頁面。它需要一些設置但編輯體驗值得。

為 SEO 構造可攜帶文本的最佳方式是什麼? 將你的可攜帶文本塊樣式映射到適當的語義 HTML。使用 h2h3h4 樣式(而不只是「大文本」或「標題」)。為結構化數據添加自訂塊類型,如常見問題部分、如何進行步驟和代碼塊。我們使用 @portabletext/react 將可攜帶文本呈現為 HTML,使用輸出 schema.org 友善標記的自訂序列化器。

你如何使用 Sanity 處理圖像優化? Sanity 的圖像管道非常出色。使用 @sanity/image-url 生成具有特定尺寸和格式參數的 URL。始終設置 auto=format,讓 Sanity 根據瀏覽器支持提供 WebP 或 AVIF。對於 Next.js 專案,我們使用 Sanity 圖像加載器與 next/image -- 這為你提供了 Sanity 的 CDN 和 Next.js 的內置圖像優化。

Sanity 能在規模上處理本地化/多語言內容嗎? 是的,但你的 schema 設計非常重要。我們使用文檔級國際化模式(按共享 i18nId 欄位連結的每個區域設置的單獨文檔),而不是欄位級翻譯物件。在 3,000+ 個跨三個區域設置的文檔上,這保持查詢簡單並避免了當每個欄位包含具有 5+ 語言鍵的物件時你獲得的大量文檔大小。

你應該多久更新一次你的 Sanity API 版本? 將你的 API 版本固定到特定日期(如 2025-01-01),並在查看變更日誌後每季度更新一次。Sanity 的 API 版本控制是基於日期的,破壞性更改很罕見,但它們確實會發生。我們曾因 API 版本之間未記錄的 GROQ 行為更改而被咬 -- 始終在碰撞版本後測試你的關鍵查詢。

大型編輯團隊的 Sanity 成本是多少? 截至 2025 年中期,每月 99 美元的成長計畫包括 100 萬 API 請求、50 萬 API CDN 請求和 20 個用戶。對於大多數編輯團隊每週發布 20-50 篇文章,這已綽綽有餘。主要成本驅動力是 API 請求 -- 來自你的前端的每個 GROQ 查詢都會計數。積極使用 CDN,在可能的地方進行緩存,並避免隨著流量倍增的客戶端獲取。