Use Cases (更新: 2026/6/7)

Sanity CMSの使い方:スキーマ・GROQ・Next/Astro取得を実装で覚える

Sanity CMSをゼロから本番運用するまで。スキーマ定義、Studio、GROQクエリ、画像配信、Next.js/Astroからの取得とプレビューを、コピペで動くコードと僕の失敗談つきで解説。

Sanity CMSの使い方:スキーマ・GROQ・Next/Astro取得を実装で覚える

最初にSanityを触ったとき、僕は管理画面で記事を10本ほど入力して、満足して帰りました。翌日フロントから取得しようとして固まった。一覧ページに本文の全文がドサッと乗ってきて、ビルドが妙に遅い。しかも下書きのつもりだった記事が、本番ページに堂々と出ていたんです。

ヒヤッとしました。CMSを「記事を置く箱」だと思っていたのが間違いでした。Sanityは、箱というよりコンテンツの設計図そのものを書くツールです。型(スキーマ)をどう切るか、何を取り出すか(GROQ)、下書きをどう除くか。ここを最初にミスると、あとで全部やり直しになります。

この記事では、その「最初に決めるべきこと」を、スキーマ → Studio → GROQ → 画像 → Next.js/Astroからの取得 → プレビューの順に、手を動かしながら追います。コードは僕が実際に動かした形に絞ってあります。

この記事の要点

  • Sanityは管理画面と表示画面を分けるヘッドレスCMS。コンテンツの「型」をコードで定義し、GROQで必要な分だけ取り出す。
  • 最初の山場はスキーマ設計。本文に何でも詰めず、SEO項目やCTAを別フィールドに切っておくと後で泣かない。
  • GROQは一覧用と詳細用を分ける。一覧で本文全文を返すのがビルド遅延の元凶。
  • 下書き除外は手作業でなく、apiVersion2025-02-19 以降に固定して perspective: "published" に任せるのが今のやり方。
  • Next.jsもAstroも @sanity/client で同じデータを取れる。画像は @sanity/image-url でサイズ最適化する。

Sanity CMSは何が違うのか

ヘッドレスCMSというのは、編集する場所(管理画面)と見せる場所(サイトやアプリ)を切り離した仕組みです。WordPressのように「管理画面とテーマがセット」ではない。だから同じ記事を、Webサイト・ニュースレター・スマホアプリ・社内ツールへ、同じデータから配れます。

Sanityがほかと違うのは、コンテンツの型をTypeScriptに近いコードで書く点です。「記事には必須のタイトルと120字以内の説明文と画像altがある」といったルールを、画面のポチポチ設定ではなくコードで宣言する。これがGitに乗り、レビューでき、Claude Codeにも書かせやすい。

データの実体は「Content Lake」というクラウドに入り、そこから GROQ(Graph-Relational Object Queries) という問い合わせ言語で必要なフィールドだけ抜き出します。SQLを知っていれば「SELECTの強い版」くらいの感覚で読めます。

公式の入り口はここです。型はSchema Types、クエリはGROQ syntax。手を動かす前にブックマークしておくと迷子になりません。

自作するかヘッドレスに乗るかで迷っている段階なら、判断軸を別記事に分けてあります。ブログCMSは自作かヘッドレスかを先に読むと、そもそもSanityを入れるべきかが見えます。この記事は「Sanityで行く」と決めた人向けです。

まず動く最小スキーマを書く

説明より先に、動く投稿スキーマを置きます。Sanity v3系の schemaTypes/post.ts としてそのまま使える形です。ポイントは、本文(body)とは別に、SEOの説明文・画像alt・CTAを独立したフィールドに切ってあること。ここをケチると後で移行地獄になります。

// schemaTypes/post.ts
import {defineField, defineType} from 'sanity'

export const post = defineType({
  name: 'post',
  title: '記事',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'タイトル',
      type: 'string',
      validation: (rule) => rule.required().max(90),
    }),
    defineField({
      name: 'slug',
      title: 'スラッグ',
      type: 'slug',
      // タイトルから自動生成。URLの一意性はここで担保する
      options: {source: 'title', maxLength: 96},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'locale',
      title: '言語',
      type: 'string',
      options: {
        list: [
          {title: '日本語', value: 'ja'},
          {title: 'English', value: 'en'},
          {title: '中文', value: 'zh'},
        ],
      },
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'description',
      title: 'SEO説明文',
      type: 'text',
      rows: 3,
      // 120字を超えると検索結果で切れるので上限を強制
      validation: (rule) => rule.required().max(120),
    }),
    defineField({
      name: 'heroImage',
      title: 'アイキャッチ画像',
      type: 'image',
      options: {hotspot: true}, // 切り抜きの焦点を編集者が指定できる
      fields: [
        defineField({
          name: 'alt',
          title: '代替テキスト',
          type: 'string',
          validation: (rule) => rule.required(), // altなし画像を弾く
        }),
      ],
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'body',
      title: '本文',
      type: 'array',
      // Portable Text。文章と画像を混在できる構造化本文
      of: [{type: 'block'}, {type: 'image'}],
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'cta',
      title: '誘導ボタン(CTA)',
      type: 'object',
      fields: [
        defineField({name: 'label', title: 'ボタン文言', type: 'string'}),
        defineField({name: 'href', title: 'リンク先URL', type: 'url'}),
        defineField({name: 'intent', title: '意図', type: 'string'}),
      ],
    }),
    defineField({
      name: 'publishedAt',
      title: '公開日時',
      type: 'datetime',
      validation: (rule) => rule.required(),
    }),
  ],
  preview: {
    // Studioの一覧で何を見せるか
    select: {title: 'title', subtitle: 'locale', media: 'heroImage'},
  },
})

書いたスキーマは、必ず一覧に登録します。schemaTypes/index.ts のここを忘れると、Studioに型が出てこなくて「あれ?」となります。僕は初回これで30分溶かしました。

// schemaTypes/index.ts
import {post} from './post'

export const schemaTypes = [post]

title は90字、description は120字、alt は必須——こうやって入力時点で弾く門番をスキーマに埋め込んでおくのがコツです。公開前レビューで毎回「説明文が長い」と指摘するより、最初から入れさせないほうが運用が続きます。

Studioを立ち上げて記事を入れる

スキーマができたら、編集画面(Sanity Studio)を起動します。Studioはローカルで動く管理画面で、sanity パッケージに入っています。

# Studioプロジェクトを作る(対話で project と dataset を選ぶ)
npm create sanity@latest -- --template clean --typescript
cd <作ったフォルダ>
npm run dev   # http://localhost:3333 でStudioが開く

開いたら左メニューに「記事」が並びます。先ほどのスキーマで required() を付けた項目は、空のままだと公開ボタンが押せません。altを入れ忘れると赤く怒られる。これが地味に効きます。人間のレビューより前に、入力UIがミスを止めてくれるからです。

ここで決めておきたいのが dataset(データの入れ物)の分け方です。productiondevelopment のように分けておくと、本番データを壊さずに型変更やクエリ実験ができます。最初は1つで始めて構いませんが、「分けられる」と知っておくだけで気が楽になります。

GROQで必要な分だけ取り出す

ここが一番大事です。GROQは一覧用と詳細用を必ず分けます。一覧ページで本文(body)まで取ると、転送量とビルド時間が膨らみ、キャッシュの扱いも悪くなる。僕が最初にやらかしたのがこれでした。

一覧では、カード表示に要る title slug description 画像 cta だけ。詳細では本文も取る。下のクエリはそのまま使えます。

// src/lib/sanity/queries.ts

// 一覧用:本文は取らない。カードに要るフィールドだけ
export const postsByLocaleQuery = `
  *[
    _type == "post" &&
    locale == $locale &&
    defined(slug.current) &&
    defined(publishedAt)
  ] | order(publishedAt desc) [0...$limit] {
    _id,
    title,
    description,
    "slug": slug.current,
    publishedAt,
    "heroImageUrl": heroImage.asset->url,
    "heroImageAlt": heroImage.alt,
    cta
  }
`

// 詳細用:本文(body)とCTAまで取る
export const postBySlugQuery = `
  *[
    _type == "post" &&
    locale == $locale &&
    slug.current == $slug
  ][0] {
    _id,
    title,
    description,
    "slug": slug.current,
    publishedAt,
    body,
    cta,
    "heroImageUrl": heroImage.asset->url,
    "heroImageAlt": heroImage.alt
  }
`

GROQの読み方を一行ずつ。* が全ドキュメント、[...] の中が絞り込み条件。-> は参照をたどる記号で、heroImage.asset->url は「画像アセットをたどってURLを取る」という意味です。最後の {...} が「返すフィールドの指定」で、ここに書いたものしか返ってきません。だから一覧から body を外すだけで、ペイロードが一気に軽くなります。

用途返すフィールド本文(body)並び替え件数制限
一覧title, slug, description, 画像, cta取らないpublishedAt降順[0...$limit]
詳細上記 + body取る不要(1件)[0]
サイトマップslug, publishedAt取らない不要全件

クライアントから取得して下書きを除外する

クエリができたら @sanity/client で叩きます。ここで2026年時点の重要な変更点があります。apiVersion2025-02-19 以降の日付に固定すると、クライアントの既定の見え方(perspective)が published になり、下書きや未公開バージョンが自動で除外されます。それより前の日付だと既定が raw で、下書きが混ざります。

つまり、冒頭で僕がやらかした「下書きが本番に出る事故」は、apiVersion の固定と perspective: "published" で防げる、ということです。defined(publishedAt) のような手作業のガードに頼らなくてよくなりました。

// src/lib/sanity/client.ts
import {createClient} from '@sanity/client'
import {postBySlugQuery, postsByLocaleQuery} from './queries'

export const sanityClient = createClient({
  projectId: process.env.SANITY_PROJECT_ID || '',
  dataset: process.env.SANITY_DATASET || 'production',
  // この日付以降だと perspective の既定が 'published'(下書き除外)になる
  apiVersion: '2025-02-19',
  perspective: 'published', // 明示しておくと意図が伝わる
  useCdn: true, // 公開ページは高速なCDN経由でOK
})

// 一覧を取る
export async function getPosts(locale: string, limit = 12) {
  return sanityClient.fetch(postsByLocaleQuery, {locale, limit})
}

// 1記事を取る
export async function getPostBySlug(locale: string, slug: string) {
  return sanityClient.fetch(postBySlugQuery, {locale, slug})
}

依存と環境変数はこれだけそろえます。Studio側は sanity、フロント側は @sanity/client、画像最適化に @sanity/image-url を入れます。

npm install sanity @sanity/client @sanity/image-url

# .env に入れる(Next.jsで公開側に出すなら NEXT_PUBLIC_ を付ける)
SANITY_PROJECT_ID=あなたのプロジェクトID
SANITY_DATASET=production

useCdn: true は公開ページ向けの設定です。あとで触れるプレビュー(下書きを見たい場面)では、ここを false にして perspective を切り替えた別クライアントを使います。

Next.jsとAstroから表示する

取得関数ができれば、フロント側はフレームワークが違っても呼び方はほぼ同じです。まずNext.js(App Router)の記事ページ。サーバーコンポーネントなので、そのまま await で取れます。

// app/[locale]/blog/[slug]/page.tsx
import {getPostBySlug} from '@/lib/sanity/client'
import imageUrlBuilder from '@sanity/image-url'
import {sanityClient} from '@/lib/sanity/client'
import {notFound} from 'next/navigation'

const builder = imageUrlBuilder(sanityClient)

export default async function PostPage(
  {params}: {params: {locale: string; slug: string}}
) {
  const post = await getPostBySlug(params.locale, params.slug)
  if (!post) notFound() // 該当なしは404へ

  // 画像は必要なサイズだけ取得して転送量を抑える
  const hero = builder.image(post.heroImageUrl).width(1200).height(630).url()

  return (
    <article>
      <h1>{post.title}</h1>
      <img src={hero} alt={post.heroImageAlt} width={1200} height={630} />
      {/* 本文(Portable Text)は @portabletext/react で描画する */}
    </article>
  )
}

Astroだとフロントマター(--- の中)でデータを取って、HTMLにそのまま流し込みます。Astroの一覧ページはこうなります。

---
// src/pages/blog/index.astro
import {getPosts} from '../../lib/sanity/client'
const posts = await getPosts('ja', 12)
---
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.slug}`}>
        <img src={post.heroImageUrl} alt={post.heroImageAlt} width="400" height="210" />
        <h2>{post.title}</h2>
        <p>{post.description}</p>
      </a>
    </li>
  ))}
</ul>

Astro側の設計(Islandsや出力モードSSG/SSRの選び方、Content Collectionsとの違い)を詰めたいなら、Astro入門:Islandsで必要な所だけJSにするに分けて書いてあります。SanityはAstroの「外部データ源」として素直に噛み合います。

画像で @sanity/image-url を使うと、width()height() でサイズ指定したURLを生成でき、巨大な元画像をそのまま配る事故を防げます。altは記事スキーマで必須にしてあるので、ここで確実に渡せます。

プレビュー:編集者に下書きを見せる

公開ページは perspective: "published" で下書きを隠しますが、編集者は公開前に見た目を確認したい。そこでプレビュー専用のクライアントを別に用意します。違いは2つだけ。perspective を下書き込みにし、useCdnfalse(CDNキャッシュをかわして最新を取る)にすることです。

// src/lib/sanity/preview-client.ts
import {createClient} from '@sanity/client'

// プレビュー専用:下書きも含めて最新を取る
export const previewClient = createClient({
  projectId: process.env.SANITY_PROJECT_ID || '',
  dataset: process.env.SANITY_DATASET || 'production',
  apiVersion: '2025-02-19',
  perspective: 'drafts', // 下書きを含める
  useCdn: false,         // キャッシュを避けて編集中の内容を反映
  token: process.env.SANITY_VIEWER_TOKEN, // 下書き閲覧には読み取りトークンが要る
})

下書きの閲覧には読み取り権限のあるトークンが必要です。これはサーバー側だけで使い、ブラウザに渡さないこと。Next.jsならドラフトモード(Draft Mode)と組み合わせて、プレビュー時だけ previewClient に切り替える作りが定番です。

注意点として、プレビュー用クライアントを本番ページで誤用しないこと。トークンが乗ったクライアントを公開側に出すと、下書きが見えてしまいます。僕は「公開用」「プレビュー用」をファイルごと分けて、import元で取り違えないようにしています。

僕がやらかした失敗3つ

正直に書きます。最初のSanity運用は穴だらけでした。

ひとつ目は、スキーマを「今の記事に要る項目」だけで作ったこと。速く立ち上がって気持ちよかったのですが、後から多言語の locale、CTAの出し分け、レビュー日を足すたびに既存データの移行が走りました。本文と分離できる項目は、最初から別フィールドにしておくべきでした。

ふたつ目は、一覧クエリで body まで取っていたこと。トップページの表示がもっさりして、原因を半日探しました。GROQの返却フィールドから本文を外しただけで、ビルドも表示も軽くなった。一覧と詳細でクエリを分ける、これだけで解決します。

みっつ目は、冒頭の下書き流出apiVersion を古い日付のままにしていて、既定が raw だったんです。2025-02-19 以降に上げて perspective: "published" にしたら、手書きの除外条件をいじらなくても下書きが消えました。バージョン固定は地味だけど効きます。

よくある質問

Q. GROQとGraphQLはどちらを使うべき? A. Sanityで素直なのはGROQです。返すフィールドを {...} で細かく削れるので、一覧の軽量化と相性がいい。GraphQLも使えますが、まずGROQで組むのをすすめます。

Q. 下書きを本番に出さないようにするには? A. apiVersion2025-02-19 以降に固定し、公開用クライアントを perspective: "published" にします。これで下書き・未公開バージョンが自動で外れます。プレビューは別クライアントで perspective: 'drafts' を使います。

Q. useCdn は true と false どちらにすべき? A. 公開ページは true(速い・安い)。プレビューや、書いた直後の内容を即反映したい場面は false。両方を1つのクライアントで兼ねず、用途ごとに分けるのが安全です。

Q. 画像はどう最適化する? A. @sanity/image-urlwidth() height() を指定したURLを生成します。元画像をそのまま配らないこと。altはスキーマで必須にしておけば渡し忘れません。

Q. Next.jsとAstro、Sanityと相性がいいのは? A. どちらも @sanity/client で同じ取得関数を使えます。JSをほぼ送らない静的サイトならAstro、動的なプレビューやISRを厚く使うならNext.jsが噛み合います。選び方はAstro入門を参照してください。

実際に試した結果

冒頭の「下書きが本番に出た」事故以来、僕がSanityで最初にやるのは3つに固定しました。スキーマで本文とSEO項目・CTAを分ける、GROQを一覧用と詳細用に割る、apiVersion2025-02-19 以降に上げて公開クライアントを published にする。これだけで、ビルドの遅さも下書き流出も止まりました。

派手なワークフローを最初から組むより、この3点を先に固めるほうが運用は続きます。CMSは記事を置く箱ではなく、型と取得の設計だと体で理解してから、ようやく楽になりました。

スキーマ設計やGROQの組み方、収益導線を含めたコンテンツ運用をまとめて固めたい場合は、研修・導入相談で既存のリポジトリと記事一覧を前提にしたレビューを依頼してください。すぐ使えるテンプレートは教材一覧にもまとめてあります。

#Sanity CMS #Headless CMS #GROQ #Next.js #Astro
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。