Sanity

リアルタイム協調編集可能なヘッドレスCMS。構造化コンテンツとPortable Textによる柔軟なコンテンツ管理。

ヘッドレスCMSリアルタイムコラボレーションGROQオープンソース柔軟性
ライセンス
MIT
言語
JavaScript/TypeScript
料金
無料プランあり

ヘッドレスCMS

Sanity

概要

Sanityは、リアルタイムコラボレーションと構造化コンテンツを特徴とするヘッドレスCMSです。独自のクエリ言語であるGROQ、カスタマイズ可能なSanity Studio、強力なポータブルテキスト機能を提供し、開発者に高い自由度を与えると同時に、コンテンツエディターにも優れた体験を提供します。

詳細

Sanityは、構造化コンテンツのプラットフォームとして設計され、データをポータブルテキスト、画像、ビデオなどの構造化された形式で管理します。Sanity StudioはオープンソースのReactアプリケーションで、完全にカスタマイズ可能です。

主な特徴:

  • リアルタイムコラボレーション: 複数ユーザーの同時編集をサポート
  • GROQ: Graph-Relational Object Queries - 強力なクエリ言語
  • Portable Text: 柔軟で拡張可能なリッチテキスト形式
  • Sanity Studio: カスタマイズ可能なコンテンツ管理UI
  • リビジョン履歴: 完全なバージョン管理
  • Asset Pipeline: 画像や動画の自動最適化
  • API CDN: グローバルCDNでの高速配信
  • プラグインシステム: Studioの機能拡張

メリット・デメリット

メリット

  • リアルタイムコラボレーション機能
  • Sanity Studioの高いカスタマイズ性
  • GROQによる強力なクエリ機能
  • ポータブルテキストによる柔軟なコンテンツ管理
  • 完全なバージョン履歴管理
  • 優れた開発者体験
  • オープンソースのStudio
  • 強力なTypeScriptサポート

デメリット

  • 学習曲線が急(特にGROQ)
  • クラウドホスティングが基本
  • 初期設定が複雑
  • 他のCMSと比較してテンプレートが少ない
  • コミュニティが相対的に小さい
  • セルフホスティングの制限

参考ページ

書き方の例

1. Hello World(基本的なセットアップ)

# Sanityプロジェクトの作成
npm create sanity@latest

# 設定オプション
# Project name: my-sanity-project
# Use default dataset: Yes
# Project output path: /path/to/project
# Select project template: Clean project
# TypeScript: Yes
# Package manager: npm
// schemas/index.ts
export const schemaTypes = [
  {
    name: 'post',
    title: 'Post',
    type: 'document',
    fields: [
      {
        name: 'title',
        title: 'Title',
        type: 'string',
        validation: Rule => Rule.required()
      },
      {
        name: 'slug',
        title: 'Slug',
        type: 'slug',
        options: {
          source: 'title',
          maxLength: 96
        }
      },
      {
        name: 'content',
        title: 'Content',
        type: 'array',
        of: [{type: 'block'}]
      }
    ]
  }
]

// Sanity Studioの起動
// npm run dev

2. コンテンツ管理

// スキーマ定義の拡張
export default {
  name: 'author',
  title: 'Author',
  type: 'document',
  fields: [
    {
      name: 'name',
      title: 'Name',
      type: 'string',
      validation: Rule => Rule.required()
    },
    {
      name: 'image',
      title: 'Image',
      type: 'image',
      options: {
        hotspot: true
      }
    },
    {
      name: 'bio',
      title: 'Bio',
      type: 'array',
      of: [
        {
          type: 'block',
          styles: [{title: 'Normal', value: 'normal'}],
          lists: []
        }
      ]
    }
  ],
  preview: {
    select: {
      title: 'name',
      media: 'image'
    }
  }
}

// リレーションの設定
{
  name: 'author',
  title: 'Author',
  type: 'reference',
  to: {type: 'author'},
  validation: Rule => Rule.required()
}

// カスタムブロックタイプ
{
  type: 'block',
  marks: {
    decorators: [
      {title: 'Strong', value: 'strong'},
      {title: 'Emphasis', value: 'em'},
      {title: 'Code', value: 'code'}
    ],
    annotations: [
      {
        name: 'link',
        type: 'object',
        title: 'URL',
        fields: [
          {
            title: 'URL',
            name: 'href',
            type: 'url'
          }
        ]
      }
    ]
  }
}

3. API操作

// Sanityクライアントの設定
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2023-10-21',
  useCdn: true
})

// GROQクエリ
const query = `*[_type == "post"]{
  _id,
  title,
  slug,
  author->{
    name,
    image
  },
  content,
  "mainImage": mainImage.asset->url
}`

const posts = await client.fetch(query)

// パラメータ付きクエリ
const query = `*[_type == "post" && slug.current == $slug][0]{
  title,
  content,
  author->{
    name,
    bio
  }
}`
const params = {slug: 'my-post'}
const post = await client.fetch(query, params)

// リアルタイムリスナー
const subscription = client.listen(
  '*[_type == "post"]',
  {},
  {includeResult: true}
).subscribe(update => {
  console.log('Update:', update)
})

// ミューテーション
client
  .patch('document-id')
  .set({title: 'New Title'})
  .inc({views: 1})
  .commit()
  .then(updatedDoc => {
    console.log('Updated:', updatedDoc)
  })

4. 認証設定

// APIトークンの設定
const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2023-10-21',
  token: process.env.SANITY_API_TOKEN, // 書き込みアクセス用
  useCdn: false // トークン使用時はCDNを無効化
})

// Studioの認証設定
// sanity.config.ts
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'

export default defineConfig({
  name: 'default',
  title: 'My Sanity Project',
  projectId: 'your-project-id',
  dataset: 'production',
  plugins: [deskTool()],
  schema: {
    types: schemaTypes,
  },
  // アクセス制御
  document: {
    actions: (prev, {schemaType}) => {
      if (schemaType === 'settings') {
        return prev.filter(action => action.action !== 'delete')
      }
      return prev
    }
  }
})

// CORS設定(Sanity管理パネルで設定)
// プロジェクト設定 > API > CORS Origins
// http://localhost:3000 を追加

5. プラグイン・拡張機能

// カスタムプラグインの作成
// plugins/myPlugin.js
import {definePlugin} from 'sanity'

export const myPlugin = definePlugin({
  name: 'my-plugin',
  // カスタムツールの追加
  tools: [
    {
      name: 'my-tool',
      title: 'My Custom Tool',
      component: MyToolComponent
    }
  ],
  // ドキュメントアクションの追加
  document: {
    actions: (prev, context) => {
      return [...prev, {
        label: 'Custom Action',
        onHandle: () => {
          console.log('Custom action triggered')
        }
      }]
    }
  }
})

// sanity.config.tsでプラグインを使用
import {myPlugin} from './plugins/myPlugin'

export default defineConfig({
  // ...
  plugins: [deskTool(), myPlugin()]
})

// カスタム入力コンポーネント
import {useFormValue} from 'sanity'

export function ConditionalField(props) {
  const document = useFormValue([])
  const isVisible = document?.category === 'news'
  
  if (!isVisible) {
    return null
  }
  
  return props.renderDefault(props)
}

// カスタムプレビュー
preview: {
  select: {
    title: 'title',
    author: 'author.name',
    media: 'mainImage'
  },
  prepare({title, author, media}) {
    return {
      title,
      subtitle: author && `by ${author}`,
      media
    }
  }
}

6. デプロイ・本番環境設定

// Next.jsとの統合
// lib/sanity.client.ts
import {createClient} from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  apiVersion: '2023-10-21',
  useCdn: process.env.NODE_ENV === 'production',
})

// プレビュー機能
// lib/sanity.preview.ts
import {definePreview} from 'next-sanity/preview'
import {projectId, dataset} from './sanity.client'

const {usePreview} = definePreview({
  projectId,
  dataset,
})

export default usePreview

// pages/api/preview.ts
export default function preview(req, res) {
  res.setPreviewData({})
  res.redirect('/')
}

// Studioのデプロイ
npm run build
# Sanity CLIでデプロイ
sanity deploy

// 環境変数の設定
// .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your-token

// Webhookの設定(Sanity管理パネル)
// API > Webhooks > Create Webhook
// URL: https://your-app.com/api/revalidate
// Trigger on: Create, Update, Delete
// Filter: _type == "post"

// ISRの実装
export async function getStaticProps({params}) {
  const post = await client.fetch(
    `*[_type == "post" && slug.current == $slug][0]`,
    {slug: params.slug}
  )

  return {
    props: {post},
    revalidate: 60
  }
}