Sanity本番運用のコツ:3,000件以上の投稿から学んだレッスン

3年以上にわたり、複数のクライアントプロジェクトでSanityを主要なCMSとして運用してきました。3,000件投稿を超えた辺りから、Sanityについて「ドキュメントに書いてあることは何か」ではなく「本番環境で実際に機能することは何か」という視点で考えるようになりました。この記事はその脳内ダンプです。私たちが後悔したスキーマの決定、ビルドを停止させたGROQクエリ、そしてエディターが実際に使いたくなるCMSになったStudioカスタマイズのすべてが詰まっています。

これは入門ガイドではありません。ここにたどり着いたあなたは、おそらくすでにSanity Studioをセットアップし、いくつかのスキーマを作成し、1つ2つのサイトをデプロイしているでしょう。私が共有したいのは、実際のコンテンツチーム、実際の編集ワークフロー、大規模での実際のパフォーマンス予算に対処した後でのみ現れるパターンです。

目次

Sanity本番運用のコツ:3,000件以上の投稿から学んだレッスン

実際のコンテンツチームに対応するスキーマ設計

スキーマ設計は、ほとんどのSanityプロジェクトが静かに失敗する場所です。劇的な炎上という方法ではなく、編集上の信頼の緩やかな浸食のような感じです。コンテンツチームが特定のフィールドを避けるようになります。彼らは回避策を作成します。6ヶ月後には、構造化されたコンテンツの半分が実は単一のリッチテキストブロックに詰め込まれています。スキーマが「複雑すぎた」からです。

オブジェクトのネストを深くしすぎない

初期の最大の過ちは、深くネストされたオブジェクト構造を作成することでした。データベーススキーマのようなコンテンツをモデル化しました。正規化され、エレガントで、技術的に正しいものです。ブログ投稿にauthor参照があり、その中にbioオブジェクトがあり、その中にsocialLinks配列があって、それぞれがplatform参照を持つオブジェクトです。

エディターはそれを嫌いました。著者のTwitterハンドルを更新する必要があるたびに、彼らは5クリック深い場所にいました。これが私たちが今やることです:

// Before: Over-engineered
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' },
              ],
            }),
          ],
        }),
      ],
    }),
  ],
})

// After: Flat, editor-friendly
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のフィールドグループは過小評価されています。6個以上のフィールドを持つすべてのドキュメントタイプに配置します:

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' }),
  ],
})

ガイドする検証、ブロックしない検証

検証をUXとして考えることを学びました。強制的な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.'),
})

これはerrorではなくwarningであることに注意してください。エディターは依然として公開できます。彼らは単に結果を知っています。

大規模でのGROQパフォーマンス:実際に重要なこと

GROQは素晴らしいですが、ある時点まで。500ドキュメントではすべてが高速です。3,000件以上のドキュメント、参照、画像、ポータブルテキストがあると、物事に気付き始めます。

プロジェクションは必須です

単一の最大GROQパフォーマンスレバーはプロジェクションです。3つのフィールドだけが必要な場合は、ドキュメント全体をフェッチするのをやめてください。generateStaticParams呼び出しでGROQプロジェクションを修正するだけで、Next.jsビルドが4分から90秒に短縮されるのを見ました。

// Slow: fetches everything including portable text, images, references
*[_type == "post"]

// Fast: only what the listing page actually needs
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

そのauthor->nameインライン逆参照が重要です。これは著者ドキュメント全体をフェッチすることを回避します。3,000の投稿があり、各投稿が50人の著者の1人を参照する場合、その違いは測定可能です。

誰も話さないジョイン問題

SanityのGROQドキュメンテーションは逆参照を無料のようにしています。無料ではありません。クエリの->は本質的にジョインです。3つ4つをスタックし、100の結果を返すリストクエリで使用すると、それを感じます。

私たちは現在プロジェクトのすべてのGROQクエリをプロファイルしています。これが経験則です:

パターン ドキュメント 平均応答時間
シンプルなフェッチ、参照なし 3,000 〜120ms
-> 逆参照の1レベル 3,000 〜250ms
->の2レベル 3,000 〜600ms
-> 内の配列ネスト 3,000 〜1,200ms以上

これらは2025年半ばの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のインフラストラクチャのサーバー側で実行されるため、通常は2ラウンドトリップを行うより高速です。

投資する価値のある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()
        // Trigger ISR revalidation or deploy hook
        await fetch('/api/revalidate', {
          method: 'POST',
          body: JSON.stringify({ type: props.type, id: props.id }),
        })
      },
    }
  }
}

編集ワークフロー用のStructure Builder

デフォルトのデスク構造はすべてのドキュメントタイプをフラットリストで表示します。15個以上のドキュメントタイプでは、これは混乱です。Structure Builderを使用して、編集中心のナビゲーションを作成します:

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(),
      // ... other content types
    ])

これはセットアップに30分かかり、毎週エディターの何時間も節約します。

Portable Textカスタムコンポーネント

私たちを大いに痛めたこと:Google DocsからPortable Textエディターにコンテンツを貼り付けるエディター。デフォルトのブロックエディターはこれをうまく処理しますが、カスタムブロックタイプは明示的なシリアライザが必要です。さもないと、空のボックスとして表示され、エディターがパニックします。

すべてのブロックタイプのカスタムコンポーネントを登録します:

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本番運用のコツ:3,000件以上の投稿から学んだレッスン - アーキテクチャ

コンテンツ移行とデータ整合性

Sanityへの主要なコンテンツ移行を5つ実行しました。WordPress、Contentful、Prismic、マークダウンファイル、およびカスタムRails CMS から移行しました。すべてのものが私たちに何か痛いことを教えました。

マイグレーションツーリングを使用しますが、信頼して確認する

Sanityの@sanity/migrateパッケージとCLIのsanity documents importは単純なケースではうまく機能します。ポータブルテキスト変換を含むすべてのものについて、カスタムスクリプトを書いてください。常に。

# Export everything for backup before any migration
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz

これはすべての移行、すべてのスキーマデプロイ、正直に毎週月曜日の朝、cronで実行します。データセットは安いです。失われたコンテンツはそうではありません。

スキーマバージョン管理戦略

Sanityはデータレイヤーでスキーマバージョンを強制しません。これは機能と足銃の両方です。古いドキュメントはスキーマを変更すると自動的に更新されません。私たちは単純なパターンを使用します:

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

その後、移行スクリプトで、*[_type == "post" && schemaVersion < 2]をクエリし、ドキュメントをバッチ更新して新しい形式に対応させることができます。粗いですが機能します。

デプロイメントと環境戦略

Sanityのデータセットモデルは複数の環境をサポートしており、初日から使用する必要があります。最初の本番データインシデント後ではなく。

私たちの標準セットアップ

環境 データセット Studio URL 目的
Production production studio.client.com ライブコンテンツ編集
Staging staging staging-studio.client.com コンテンツQA、スキーマテスト
Development development localhost:3333 スキーマ開発

sanity dataset copy production stagingを使用して本番をステージングに週1回クローンしています。これにより、スキーマ実験中に本番データを危険にさらすことなく、ステージングを現実的に保ちます。

フロントエンドについて、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(`Slow GROQ query (${duration.toFixed(0)}ms):`, query.slice(0, 200))
  }

  return result
}

本番では500msを超えるものはすべて調査されます。通常は、プロジェクトされていないクエリまたはコードレビューで忍び込んだネストされた逆参照です。

Webhook信頼性

Sanityのwebhookは信頼できますが、信頼できません。Sanityインフラストラクチャ更新中に時折、webhookが見つかることがあります。重要なワークフロー(Astro開発プロジェクトでの再構築のトリガーなど)については、ポーリングフォールバックを実装します:

// Check for recent changes every 5 minutes as a safety net
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)

実際のプロジェクトからのパフォーマンスベンチマーク

ヘッドレスフロントエンドでSanityを使用して2024~2025年に出荷した3つの本番プロジェクトからの実数:

メトリクス プロジェクト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プランコスト/月 Growth($99) Free Growth($99)

プロジェクトCの長いビルド時間は、完全にはGROQではなく、画像処理による。@sanity/image-urlで適切なwidth/heightパラメータを使用してSanityのイメージパイプラインに移動した後、ビルドはフル解像度の画像をダウンロードしなくなりました。

ヘッドレスCMS開発プロジェクトの場合、Sanityの価格設定は競争力があります。無料層は小規模なサイトで本当に使用可能です。$99/月のGrowthプランでは、ほとんどの中規模編集操作をカバーしています。非常に高いAPI要求ボリュームで費用の懸念を開始するだけで、積極的なCDN使用とスマートキャッシングでもコストは合理的です。

Sanityが適切な選択ではない場合

Sanityから離れるようクライアントを指導した場合について言及しないことで、あなたに不利益をもたらすことになります:

  • 高度に関連するデータ(複雑なバリアント関係を持つ製品カタログ)――コマース専用プラットフォームまたはPostgresでさえ、より多くの意味があります
  • 非常に技術的でないチーム、WYSIWYG page builder が必要で――SanityのPortable Textは強力ですが、Squarespaceではありません
  • 予算制約のあるプロジェクト、月間>200K APIリクエスト付き――コストは驚く可能性があります

他のすべて―特に編集コンテンツ、マーケティングサイト、ドキュメント―Sanityは私たちの頼みの綱でした。ヘッドレスプロジェクトのオプションを評価している場合は、お気軽にお問い合わせください。具体的なニーズに基づいて正直な評価を提供します。

FAQ

Sanityがパフォーマンスを低下させる前にいくつのドキュメントを処理できますか? 4,000件を超えるドキュメントを含む本番プロジェクトを有意な劣化なしで実行しました。Sanityのホストされたインフラストラクチャは、数万件のドキュメント数にうまく対応します。パフォーマンスボトルネックは、ほぼ常に、GROQクエリ、特に予測されていないフェッチとディープ参照チェーンを記述する方法です。生のドキュメント数ではありません。

SanityでGROQまたはGraphQLを使用する必要がありますか? GraphQLを使用する非常に具体的な理由がない限り、GROQ。GROQは、Sanityのドキュメントモデル、より自然にサポートプロジェクション、およびSanityチームから第1クラスの注目を得るには、より表現的です。GraphQL APIはスキーマから自動生成され、正常に機能しますが、Sanityを強力にするクエリの柔軟性を失います。

SanityとNext.jsでドラフトプレビューを処理するにはどうすればいいですか? Next.js Draft ModeをSanityのperspective: 'previewDrafts'設定と組み合わせて使用します。プレビュークライアントはCDNをバイパスし、読み取りトークンを使用します。Sanity の@sanity/preview-kitパッケージは、エディターが入力するときにページを更新するリアルタイムリスナーを提供します。セットアップには時間がかかりますが、編集エクスペリエンスの価値があります。

SEOのためにPortable Textを構造化するのに最適な方法は何ですか? Portable Textブロックスタイルを適切なセマンティックHTMLにマップします。h2h3h4スタイルを使用してください。「大きなテキスト」や「見出し」だけではなく。FAQセクション、方法のステップ、コードブロックなどの構造化データのカスタムブロックタイプを追加します。@portabletext/reactを使用してPortable Textを HTMLにレンダリングします。これにより、schema.org対応のマークアップを出力するカスタムシリアライザを使用します。

Sanityを使用して大規模に画像最適化を処理するにはどうすればいいですか? Sanityのイメージパイプラインは優れています。@sanity/image-urlを使用して、特定の寸法とフォーマットパラメータでURLを生成します。常にauto=formatを設定して、ブラウザサポートに基づいてSanityをWebPまたはAVIFで提供するようにしてください。Next.jsプロジェクトについて、Sanity image loaderをnext/imageで使用します。これにより、Sanity CDNとNext.jsの組み込み画像最適化の両方が得られます。

Sanityは大規模でローカライズ/多言語コンテンツを処理できますか? はい、ただしスキーマ設計は大きな問題です。フィールドレベルの翻訳オブジェクトではなく、ドキュメントレベルの国際化パターン(共有i18nIdフィールドでリンクされたロケール別に分離されたドキュメント)を使用します。3,000件以上のドキュメント(3つのロケール)で、これはクエリをシンプルに保ち、すべてのフィールドに5個以上の言語キーを持つオブジェクトが含まれている場合に得られる大規模なドキュメント規模を回避します。

Sanity APIバージョンをどのくらいの頻度で更新する必要がありますか? 特定の日付(2025-01-01など)にAPIバージョンをピンし、変更ログを確認した後、四半期ごとに更新します。SanityのAPI バージョン管理は日付ベースで、重大な変更はまれですが、発生します。API バージョン間の文書化されていないGROQ動作の変更に対してかまれました。常にバージョンを上げた後、重要なクエリをテストしてください。

大規模な編集チームのためのSanityのコストはいくらですか? 2025年半ばの時点では、$99/月のGrowthプランには、100万のAPIリクエスト、500KのAPI CDNリクエスト、20ユーザーが含まれます。週に20~50件の投稿を公開するほとんどの編集チームについて、これは十分以上です。主なコストドライバーはAPIリクエストです。フロントエンドからのすべてのGROQクエリはカウントします。CDNを積極的に使用し、可能な限りキャッシュし、トラフィック増加で乗算するクライアント側のフェッチを避けてください。