我们在多个客户项目中使用 Sanity 作为主要 CMS 已经超过三年。在 3000+ 篇文章的时候,你不再按照文档说的来思考 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: 'Content', default: true },
    { name: 'seo', title: 'SEO' },
    { name: 'settings', title: 'Settings' },
  ],
  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' }),
  ],
})

指导而不是阻挡的验证

我们学会了将验证视为用户体验,而不是强制。在每个字段上进行硬 required() 验证意味着编辑不能保存草稿。解释为什么某些内容重要的自定义验证消息比通用错误状态获得更好的合规性:

defineField({
  name: 'excerpt',
  type: 'text',
  rows: 3,
  validation: (rule) =>
    rule
      .max(160)
      .warning('Excerpts over 160 characters get truncated in search results and social cards.'),
})

注意这是一个 warning,不是 error。编辑仍然可以发布。他们只是知道后果。

大规模 GROQ 性能:什么真正重要

GROQ 很棒,直到它不再棒。在 500 个文档时,一切都很快。在有 3000+ 个文档、引用、图像和可移植文本的情况下,你开始注意到事情。

投影不是可选的

GROQ 性能的最大杠杆是投影。当你只需要三个字段时,停止获取整个文档。我看到仅通过修复 generateStaticParams 调用中的 GROQ 投影,Next.js 构建就从 4 分钟变成了 90 秒。

// 慢:获取包括可移植文本、图像、引用在内的所有内容
*[_type == "post"]

// 快:仅列表页面实际需要的内容
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

那个 author->name 内联解引用是关键。它避免了获取整个作者文档。当你有 3000 篇文章,每篇都引用 50 位作者中的一位时,差异是可以测量的。

没人谈论的连接问题

Sanity 的 GROQ 文档展示解引用就像它是免费的。它不是。查询中的每个 -> 本质上都是一个连接。在返回 100 个结果的列表查询中堆积三个或四个,你会感受到。

我们现在对我们项目中的每个 GROQ 查询进行分析。以下是我们的经验法则:

模式 文档数 平均响应时间
简单获取,无引用 3,000 ~120ms
一级 -> 解引用 3,000 ~250ms
两级 -> 3,000 ~600ms
嵌套数组,内部有 -> 3,000 ~1,200ms+

这些是我们来自 2026 年中期 Sanity API 仪表板的真实数字。你的情况可能会因文档大小而异,但趋势是一致的。

我们经常使用的 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 篇文章的内容团队来说,它并不好。以下是我们在每个项目上定制的内容。

自定义文档操作

默认发布操作在每个设置中都不能可靠地触发 webhooks。我们将其包装:

import { useDocumentOperation } from 'sanity'

export function createPublishWithWebhookAction(originalPublishAction) {
  return function PublishWithWebhook(props) {
    const originalResult = originalPublishAction(props)
    return {
      ...originalResult,
      onHandle: async () => {
        await originalResult.onHandle()
        // 触发 ISR 重新验证或部署 hook
        await fetch('/api/revalidate', {
          method: 'POST',
          body: JSON.stringify({ type: props.type, id: props.id }),
        })
      },
    }
  }
}

用于编辑工作流的结构构建器

默认的 desk 结构在扁平列表中显示每个文档类型。在 15+ 个文档类型时,这是一片混乱。我们使用结构构建器来创建编辑焦点导航:

import { StructureBuilder } from 'sanity/structure'

export const structure = (S: StructureBuilder) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Blog')
        .child(
          S.list()
            .title('Blog')
            .items([
              S.listItem()
                .title('Published Posts')
                .child(
                  S.documentList()
                    .title('Published')
                    .filter('_type == "post" && !(_id in path("drafts.**"))')
                ),
              S.listItem()
                .title('Drafts')
                .child(
                  S.documentList()
                    .title('Drafts')
                    .filter('_type == "post" && _id in path("drafts.**")')
                ),
              S.listItem()
                .title('All Posts')
                .child(S.documentTypeList('post').title('All Posts')),
            ])
        ),
      S.divider(),
      // ... 其他内容类型
    ])

这需要 30 分钟来设置,但每周为编辑节省数小时的困惑。

可移植文本自定义组件

我们遇到的一个问题:编辑将内容从 Google Docs 粘贴到可移植文本编辑器中。默认块编辑器可以处理这个问题,但自定义块类型需要显式序列化器,否则它们显示为空框,编辑们会慌张。

我们为每个块类型注册自定义组件:

defineArrayMember({
  type: 'object',
  name: 'codeBlock',
  title: 'Code Block',
  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: `Code (${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: '2026-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(`Slow GROQ query (${duration.toFixed(0)}ms):`, query.slice(0, 200))
  }

  return result
}

生产中超过 500ms 的任何内容都会被调查。通常它是一个未投影的查询或在代码审查中偷偷进来的嵌套解引用。

Webhook 可靠性

Sanity webhooks 是可靠的但并非无懈可击。我们在 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? 除非你有非常具体的理由使用 GraphQL,否则使用 GROQ。GROQ 对 Sanity 的文档模型更具表现力,更自然地支持投影,并获得 Sanity 团队的第一流关注。GraphQL API 是从你的 schema 自动生成的,可以很好地工作,但你失去了使 Sanity 强大的一些查询灵活性。

你如何使用 Sanity 和 Next.js 处理草稿预览? 我们将 Next.js 草稿模式与 Sanity 的 perspective: 'previewDrafts' 设置结合使用。预览客户端绕过 CDN 并使用读取令牌。Sanity 的 @sanity/preview-kit 包提供实时侦听器,在编辑输入时更新页面。它需要一些设置,但编辑体验是值得的。

构建可移植文本以获得 SEO 的最佳方法是什么? 将可移植文本块样式映射到适当的语义 HTML。使用 h2h3h4 样式(而不仅仅是 "大文本" 或 "标题")。为 FAQ 部分、操作方法步骤和代码块等结构化数据添加自定义块类型。我们使用 @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 版本固定为特定日期(如 2026-01-01),并在审查更改日志后每季度更新一次。Sanity 的 API 版本控制是基于日期的,破坏性变化很少见,但它们确实会发生。我们被 API 版本之间的未记录 GROQ 行为变化所困扰 -- 总是在碰到版本后测试你的关键查询。

Sanity 对于大型编辑团队的成本是多少? 增长计划(截至 2026 年中期)每月 $99,包括 1M API 请求、500K API CDN 请求和 20 个用户。对于大多数每周发布 20-50 篇文章的编辑团队,这已经足够了。主要的成本驱动因素是 API 请求 -- 来自你的前端的每个 GROQ 查询都计数。积极地使用 CDN,尽可能缓存,并避免乘以流量的客户端获取。