3年以上にわたり、複数のクライアントプロジェクト全体でSanityを主要なCMSとして運用してきました。3000投稿あたりのマークを通過すると、Sanityについて「ドキュメントに書いてあること」ではなく「実際に本番環境で機能すること」で考え始めます。この記事はそのすべてです -- 後悔した各スキーマの決定、ビルドを停止させたすべてのGROQクエリ、そしてエディターが実際にCMSを使いたくなり、Word文書をメールで送ってこなくなったすべてのStudioカスタマイズ。

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

目次

Sanity Studio本番運用のコツ:3000以上の投稿から学んだレッスン

実際のコンテンツチームを耐え抜くスキーマ設計

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

オブジェクトの過度なネストを停止する

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

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

// Before: 過度に設計された
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: フラット、エディタフレンドリー
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ドキュメントで、すべてが高速です。3000以上のドキュメントで参照、画像、ポータブルテキストを使用すると、何かに気づき始めます。

プロジェクションはオプションではありません

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

// Slow: ポータブルテキスト、画像、参照を含むすべてを取得
*[_type == "post"]

// Fast: リスティングページが実際に必要なもののみ
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

そのauthor->nameインライン逆参照が重要です。著者ドキュメント全体を取得することを避けます。3000の投稿があり、それぞれ50人の著者のうち1人を参照する場合、違いは測定可能です。

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

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

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

パターン ドキュメント 平均応答時間
シンプルフェッチ、参照なし 3,000 ~120ms
1レベルの->逆参照 3,000 ~250ms
2レベルの-> 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のインフラストラクチャ内でサーバー側で実行されるため、通常は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()
        // 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('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分で設定でき、エディターが毎週数時間の混乱を節約します。

ポータブルテキストカスタムコンポーネント

私たちを厳しく噛んだ1つのこと:エディターが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への5つの主要なコンテンツ移行を実施しました -- WordPress、Contentful、Prismic、マークダウンファイル、カスタムRails CMSから。それぞれがう1つ、何か痛みを伴うことを教えてくれました。

移行ツーリングを使用しますが、信頼して検証する

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

# すべての移行前にバックアップ用にすべてをエクスポート
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 studio.client.com ライブコンテンツ編集
ステージング staging staging-studio.client.com コンテンツQA、スキーマテスト
開発 development localhost:3333 スキーマ開発

sanity dataset copy production stagingを使用して毎週本番環境をステージングにクローンします。これによりステージングを現実的に保ちながら、スキーマ実験中に本番環境データのリスクを避けられます。

フロントエンドの場合、私たちの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を設定します。

本番環境でのモニタリングとデバッグ

GROQLクエリプロファイリング

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開発プロジェクトでリビルドをトリガーするなど)の場合、ポーリングフォールバックを実装します:

// 安全ネットとして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とヘッドレスフロントエンドで配信した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) 無料 Growth ($99)

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

ヘッドレスCMS開発プロジェクトの場合、Sanityの価格は競争力があります。無料ティアは小さいサイトで本当に使用可能です。成長プランは月$ 99で、ほとんどの中規模編集操作をカバーしています。非常に高いAPIリクエストボリュームで費用の懸念が始まり、積極的なCDN使用とスマートキャッシングでさえ物事を合理的に保ちます。

Sanityが正しい選択ではない場合

Sanityからお客様を遠ざけた場合があるので、あなたに不名誉をすることはありません:

  • 高度にリレーショナルデータ (複雑なバリアント関係を持つ製品カタログ) -- 目的別の商業プラットフォームまたはPostgresでさえ、より意味があります
  • 非常に非技術的なチーム WYSIWYGページビルダーが必要です -- SanityのポータブルテキストはパワフルですがSquarespaceではありません
  • 予算制約のあるプロジェクト 月200Kを超えるAPIリクエスト -- コストは驚くことができます

他のすべて -- 特にエディタルコンテンツ、マーケティングサイト、ドキュメンテーション -- Sanityが私たちのgo-toというCMSでした。ヘッドレスプロジェクトのオプションを評価している場合は、お問い合わせください。特定のニーズに基づいて正直な評価をさせていただきます。

FAQ

パフォーマンスが低下する前に、Sanityは何個のドキュメントを処理できますか?

意味のある低下なしに、4000以上のドキュメントを持つ本番環境プロジェクトを実行しました。Sanityのホストされたインフラストラクチャはドキュメント数をうまく処理します。数万に至るまで。パフォーマンスのボトルネックは、ほぼ常にGROQLクエリの記述方法です -- 具体的には、プロジェクションされていないフェッチおよびディープ参照チェーン -- 生のドキュメント数ではありません。

SanityでGROQLまたはGraphQLを使用する必要がありますか?

GROQL、GraphQLを使用する非常に具体的な理由がない限り。GROQLはSanityのドキュメントモデルの方が表現力が高く、プロジェクションをより自然にサポートし、Sanityチームから1級の注意を受けます。GraphQL APIはスキーマから自動生成され、うまく機能しますが、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プロジェクトの場合、next/imageでSanity画像ローダーを使用します -- これはSanityのCDNとNext.jsの組み込み画像最適化の両方を提供します。

Sanityは規模でローカライズ/多言語コンテンツを処理できますか?

はい、ですがスキーマ設計が大幅に重要です。ドキュメントレベルの国際化パターン(共有i18nIdフィールドでリンクされたロケールごとの別のドキュメント)を使用します。フィールドレベルの翻訳オブジェクトの代わりに。3つのロケール全体で3000以上のドキュメントで、これはクエリをシンプルに保ち、すべてのフィールドが5+言語キーを持つオブジェクトを含むときに取得される大規模なドキュメントサイズを回避します。

Sanity APIバージョンをどのくらいの頻度で更新する必要がありますか?

APIバージョンを特定の日付(例:2026-01-01)にピン止めし、チェンジログを確認した後、四半期ごとに更新します。SanityのAPIバージョン管理は日付ベースであり、重大な変更はまれですが、発生します。APIバージョン間での文書化されていないGROQLの動作変更によって噛まれています -- バージョンをバンプした後、常に重要なクエリをテストします。

大規模な編集チームのためのSanityのコストはいくらですか?

成長プランは月$ 99(2026年中頃現在)で、1M APIリクエスト、500K API CDNリクエスト、20ユーザーが含まれます。ほとんどの編集チームが週に20~50投稿を公開している場合、これは十分以上です。主要なコストドライバーはAPIリクエストです -- フロントエンドからのすべてのGROQLクエリがカウントされます。CDNを積極的に使用して、可能な場所をキャッシュし、クライアント側のフェッチを避けます。