sanity-cms について
sanity-cms は、Sanity CMS を使う開発者向けの実装スキルです。スポーツクラブのコンテンツモデルや TypeScript、GROQ クエリに加え、Next.js の画像、プレビュー、キャッシュの実践方法を扱います。
主な機能
- 01スポーツクラブ向けコンテンツモデル
- 02TypeScript の型生成とクエリ
- 03GROQ の絞り込み・並べ替え・ページネーション
- 04Next.js の画像と下書きプレビュー
- 05キャッシュ・Webhook・再検証
活用シーン
- 01クラブのニュース、試合日程、選手をモデル化
- 02型安全なコンテンツクエリを生成
- 03Next.js で下書きコンテンツを表示
- 04CMS 更新後にページを再検証
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ dejanvasic85/ williamstownsc --skill sanity-cmsインストール後、Claude Code で/sanity-cmsを入力して読み込まれたか確認します。
詳細
sanity-cms
手順
プロジェクトに Sanity CMS を実装するには、https://www.sanity.io/learn/llms.txt のドキュメントに従ってください。このスキルでは、スポーツクラブのコンテンツモデリング、TypeScript との統合、Next.js の最適化に関するプロジェクト固有のパターンを提供します。
スポーツクラブ向けのコンテンツモデリング
主要なスキーマ型
Williamstown SC のウェブサイトには、次の主要なコンテンツ型が必要です。
- blogPost - クラブのニュース、お知らせ、試合レポート
- event - 試合、トレーニングセッション、クラブイベント
- player - チームの選手一覧と選手プロフィール
- fixture - 試合日程、結果、統計
- sponsor - クラブのスポンサーとパートナー
- page - 静的ページ(About、Contact など)
- teamMember - コーチングスタッフと委員会メンバー
スキーマのベストプラクティス
命名規則
一貫性を保つため、次の規則に従ってください。
// Schema files: camelCase.ts
blogPost.ts;
teamMember.ts;
fixtureResult.ts;
// Field names: camelCase
publishedAt;
featuredImage;
homeTeamScore;
// Document types: camelCase
blogPost;
teamMember;
fixtureResult;
必須フィールドのパターン
すべてのドキュメント型に、次の基本フィールドを含めてください。
{
name: 'blogPost', // or your document type
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required()
},
// _createdAt and _updatedAt are automatic
// Additional fields...
]
}
SEO メタデータのパターン
すべてのコンテンツ型で再利用できる SEO オブジェクトです。
// schemas/objects/seo.ts
export default {
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
{
name: 'metaTitle',
type: 'string',
title: 'Meta Title',
description: 'Title for search engines (50-60 characters)',
validation: (Rule) => Rule.max(60)
},
{
name: 'metaDescription',
type: 'text',
title: 'Meta Description',
description: 'Description for search engines (120-160 characters)',
validation: (Rule) => Rule.min(120).max(160)
},
{
name: 'ogImage',
type: 'image',
title: 'Social Share Image',
description: 'Recommended: 1200x630px'
},
]
}
// Use in document schemas:
{
name: 'seo',
type: 'seo',
title: 'SEO Settings'
}
ドキュメントスキーマの例
ブログ投稿スキーマ
// schemas/documents/blogPost.ts
export default {
name: 'blogPost',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'title',
maxLength: 96
},
validation: (Rule) => Rule.required()
},
{
name: 'publishedAt',
type: 'datetime',
title: 'Published At',
validation: (Rule) => Rule.required()
},
{
name: 'excerpt',
type: 'text',
title: 'Excerpt',
description: 'Short summary for cards and previews',
rows: 3,
validation: (Rule) => Rule.max(200)
},
{
name: 'mainImage',
type: 'image',
title: 'Main Image',
options: {
hotspot: true
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
}
]
},
{
name: 'categories',
type: 'array',
title: 'Categories',
of: [{ type: 'reference', to: [{ type: 'category' }] }]
},
{
name: 'body',
type: 'array',
title: 'Body',
of: [
{ type: 'block' },
{
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text'
}
]
}
]
},
{
name: 'featured',
type: 'boolean',
title: 'Featured Post',
description: 'Display on homepage'
},
{
name: 'seo',
type: 'seo',
title: 'SEO Settings'
}
],
preview: {
select: {
title: 'title',
media: 'mainImage',
subtitle: 'publishedAt'
}
}
};
試合日程/試合結果スキーマ
// schemas/documents/fixture.ts
export default {
name: 'fixture',
title: 'Fixture',
type: 'document',
fields: [
{
name: 'matchDate',
type: 'datetime',
title: 'Match Date & Time',
validation: (Rule) => Rule.required()
},
{
name: 'competition',
type: 'string',
title: 'Competition',
options: {
list: [
{ title: 'NPL Victoria', value: 'npl' },
{ title: 'FFA Cup', value: 'ffa-cup' },
{ title: 'State League', value: 'state-league' }
]
}
},
{
name: 'homeTeam',
type: 'string',
title: 'Home Team',
validation: (Rule) => Rule.required()
},
{
name: 'awayTeam',
type: 'string',
title: 'Away Team',
validation: (Rule) => Rule.required()
},
{
name: 'homeScore',
type: 'number',
title: 'Home Score',
description: 'Leave empty for upcoming matches'
},
{
name: 'awayScore',
type: 'number',
title: 'Away Score',
description: 'Leave empty for upcoming matches'
},
{
name: 'venue',
type: 'string',
title: 'Venue',
validation: (Rule) => Rule.required()
},
{
name: 'isHomeGame',
type: 'boolean',
title: 'Is Home Game',
description: 'Is this a Williamstown SC home game?'
},
{
name: 'matchReport',
type: 'array',
title: 'Match Report',
description: 'Detailed match report (optional)',
of: [{ type: 'block' }]
},
{
name: 'highlights',
type: 'url',
title: 'Highlights Video URL',
description: 'YouTube or other video platform URL'
}
],
preview: {
select: {
homeTeam: 'homeTeam',
awayTeam: 'awayTeam',
homeScore: 'homeScore',
awayScore: 'awayScore',
date: 'matchDate'
},
prepare({ homeTeam, awayTeam, homeScore, awayScore, date }) {
const score =
homeScore !== undefined && awayScore !== undefined ? `${homeScore}-${awayScore}` : 'vs';
return {
title: `${homeTeam} ${score} ${awayTeam}`,
subtitle: new Date(date).toLocaleDateString()
};
}
}
};
選手プロフィールスキーマ
// schemas/documents/player.ts
export default {
name: 'player',
title: 'Player',
type: 'document',
fields: [
{
name: 'name',
type: 'string',
title: 'Full Name',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'name',
maxLength: 96
}
},
{
name: 'number',
type: 'number',
title: 'Squad Number',
validation: (Rule) => Rule.min(1).max(99)
},
{
name: 'position',
type: 'string',
title: 'Position',
options: {
list: [
{ title: 'Goalkeeper', value: 'GK' },
{ title: 'Defender', value: 'DEF' },
{ title: 'Midfielder', value: 'MID' },
{ title: 'Forward', value: 'FWD' }
]
},
validation: (Rule) => Rule.required()
},
{
name: 'photo',
type: 'image',
title: 'Player Photo',
options: {
hotspot: true
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
}
]
},
{
name: 'bio',
type: 'text',
title: 'Biography',
rows: 4
},
{
name: 'stats',
type: 'object',
title: 'Season Statistics',
fields: [
{
name: 'appearances',
type: 'number',
title: 'Appearances',
initialValue: 0
},
{
name: 'goals',
type: 'number',
title: 'Goals',
initialValue: 0
},
{
name: 'assists',
type: 'number',
title: 'Assists',
initialValue: 0
}
]
}
],
preview: {
select: {
title: 'name',
number: 'number',
position: 'position',
media: 'photo'
},
prepare({ title, number, position, media }) {
return {
title: `${number ? `#${number} ` : ''}${title}`,
subtitle: position,
media
};
}
}
};
TypeScript との統合
型を生成する
package.json に追加します。
{
"scripts": {
"sanity:typegen": "sanity schema extract && sanity typegen generate"
}
}
スキーマを変更した後に実行します。
npm run sanity:typegen
生成された型を使用する
// Import generated types
import type {BlogPost, Fixture, Player} from '@/sanity/types'
// Type-safe data fetching
const posts: BlogPost[] = await client.fetch(query)
// Type-safe component props
interface NewsCardProps {
post: BlogPost
}
const NewsCard = ({post}: NewsCardProps) => {
return (
<article>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
)
}
型安全な GROQ クエリ
import { groq } from 'next-sanity';
import type { BlogPost } from '@/sanity/types';
const query = groq`
*[_type == "blogPost"] | order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
excerpt,
"mainImage": mainImage.asset->url,
"categories": categories[]->title
}
`;
const posts = await client.fetch<BlogPost[]>(query);
GROQ クエリのパターン
よく使うクエリ
最新のブログ投稿
*[_type == "blogPost"] | order(publishedAt desc)[0...10] {
_id,
title,
slug,
excerpt,
publishedAt,
"image": mainImage.asset->url,
"imageAlt": mainImage.alt,
"categories": categories[]->title,
featured
}
今後の試合
*[_type == "fixture" && matchDate > now()] | order(matchDate asc) {
_id,
matchDate,
homeTeam,
awayTeam,
venue,
competition,
isHomeGame
}
過去の試合結果
*[_type == "fixture" && matchDate < now() && defined(homeScore)] | order(matchDate desc)[0...10] {
_id,
matchDate,
homeTeam,
awayTeam,
homeScore,
awayScore,
venue,
isHomeGame
}
ポジション別の選手一覧
*[_type == "player"] | order(position asc, number asc) {
_id,
name,
number,
position,
"photo": photo.asset->url,
"photoAlt": photo.alt,
stats
}
全コンテンツを含む単一の投稿
*[_type == "blogPost" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
body,
"mainImage": mainImage.asset->url,
"mainImageAlt": mainImage.alt,
"categories": categories[]->{
_id,
title,
slug
},
seo
}
参照の展開
// Single reference with ->
"author": author->name,
"category": category->title,
// Array of references with []->
"tags": tags[]->title,
"players": players[]-> {
name,
number,
position
},
// Nested references
"author": author-> {
name,
"image": image.asset->url
}
フィルタリングと並べ替え
// Filter by multiple conditions
*[_type == "blogPost" && featured == true && publishedAt < now()]
// Filter with references
*[_type == "blogPost" && references(*[_type == "category" && title == "News"]._id)]
// Date filtering
*[_type == "fixture" && matchDate >= $startDate && matchDate <= $endDate]
// Sorting
| order(publishedAt desc)
| order(matchDate asc)
| order(position asc, number asc) // Multiple fields
ページネーション
// First 10 results
*[_type == "blogPost"] | order(publishedAt desc)[0...10]
// Next 10 results (11-20)
*[_type == "blogPost"] | order(publishedAt desc)[10...20]
// Using variables
*[_type == "blogPost"] | order(publishedAt desc)[$start...$end]
画像の最適化
検証付き画像スキーマ
{
name: 'mainImage',
type: 'image',
title: 'Main Image',
options: {
hotspot: true, // Enable focal point selection
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
description: 'Describe the image for accessibility',
validation: (Rule) => Rule.required().error('Alt text is required for accessibility')
},
{
name: 'caption',
type: 'string',
title: 'Caption',
description: 'Optional caption to display below image'
}
],
validation: (Rule) => Rule.required()
}
画像 URL ビルダー
// lib/sanity/image.ts
import imageUrlBuilder from '@sanity/image-url';
// Usage:
import { urlFor } from '@/lib/sanity/image';
import { client } from './client';
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
const imageUrl = urlFor(post.mainImage).width(800).height(600).fit('crop').url();
Next.js の画像統合
import Image from 'next/image';
import { urlFor } from '@/lib/sanity/image';
<Image
src={urlFor(post.mainImage).width(800).height(600).url()}
alt={post.mainImage.alt}
width={800}
height={600}
className="rounded-lg"
/>;
レスポンシブ画像
// Generate srcset for responsive images
function getImageSrcSet(image: any, widths: number[] = [400, 800, 1200]) {
return widths.map(width =>
`${urlFor(image).width(width).url()} ${width}w`
).join(', ')
}
// Usage in component:
<img
src={urlFor(image).width(800).url()}
srcSet={getImageSrcSet(image)}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
alt={image.alt}
/>
Portable Text(リッチテキスト)
スキーマの設定
{
name: 'body',
title: 'Body',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
{title: 'Underline', value: 'underline'},
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Link',
fields: [
{
name: 'href',
type: 'url',
title: 'URL',
validation: (Rule) => Rule.required()
}
]
}
]
}
},
{
type: 'image',
options: {hotspot: true},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
},
{
name: 'caption',
type: 'string',
title: 'Caption'
}
]
}
]
}
Portable Text のレンダリング
import {PortableText} from '@portabletext/react'
import Image from 'next/image'
import {urlFor} from '@/lib/sanity/image'
const components = {
block: {
h1: ({children}) => (
<h1 className="text-4xl font-bold mb-4">{children}</h1>
),
h2: ({children}) => (
<h2 className="text-3xl font-bold mb-3">{children}</h2>
),
h3: ({children}) => (
<h3 className="text-2xl font-bold mb-2">{children}</h3>
),
normal: ({children}) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
},
marks: {
link: ({children, value}) => (
<a
href={value.href}
className="text-primary underline hover:text-primary-focus"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
},
types: {
image: ({value}) => (
<figure className="my-8">
<Image
src={urlFor(value).width(1200).url()}
alt={value.alt || 'Blog post image'}
width={1200}
height={800}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-sm text-center mt-2 text-base-content/70">
{value.caption}
</figcaption>
)}
</figure>
),
},
}
// Usage:
<PortableText value={post.body} components={components} />
プレビューとドラフトモード
Next.js でドラフトモードを有効にする
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Verify secret token
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
// Enable draft mode
draftMode().enable();
// Redirect to the path
redirect(slug || '/');
}
ドラフトモードを無効にする
// app/api/exit-draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
draftMode().disable();
redirect('/');
}
ドラフトコンテンツを含めて取得する
import { draftMode } from 'next/headers';
import { client } from '@/lib/sanity/client';
export async function getPosts() {
const preview = draftMode().isEnabled;
const posts = await client.fetch(
query,
{},
{
perspective: preview ? 'previewDrafts' : 'published',
// Disable caching in preview mode
cache: preview ? 'no-store' : 'force-cache',
next: {
revalidate: preview ? 0 : 3600
}
}
);
return posts;
}
スキーマの整理
推奨ディレクトリ構造
sanity/
├── schemas/
│ ├── documents/ # Top-level content types
│ │ ├── blogPost.ts
│ │ ├── event.ts
│ │ ├── fixture.ts
│ │ ├── page.ts
│ │ ├── player.ts
│ │ └── sponsor.ts
│ ├── objects/ # Reusable objects
│ │ ├── seo.ts
│ │ ├── socialLinks.ts
│ │ └── stats.ts
│ └── index.ts # Export all schemas
├── lib/
│ ├── client.ts # Sanity client config
│ └── image.ts # Image URL builder
├── env.ts # Environment variables
└── types.ts # Generated TypeScript types
スキーマのインデックスファイル
// schemas/index.ts
import blogPost from './documents/blogPost';
import event from './documents/event';
import fixture from './documents/fixture';
import player from './documents/player';
import seo from './objects/seo';
export const schemaTypes = [
// Documents
blogPost,
event,
fixture,
player,
// Objects
seo
];
バリデーションのパターン
よく使うバリデーション
// Required field
validation: (Rule) => Rule.required();
// String length
validation: (Rule) => Rule.min(50).max(160);
// Number range
validation: (Rule) => Rule.min(0).max(100);
// URL validation
validation: (Rule) =>
Rule.uri({
scheme: ['http', 'https']
});
// Custom validation
validation: (Rule) =>
Rule.custom((value) => {
if (!value) {
return 'This field is required';
}
if (value.length < 10) {
return 'Must be at least 10 characters';
}
return true;
});
// Conditional validation
validation: (Rule) =>
Rule.custom((value, context) => {
if (context.document.featured && !value) {
return 'Featured posts must have an excerpt';
}
return true;
});
コンテンツの関連付け
参照
// Single reference
{
name: 'author',
title: 'Author',
type: 'reference',
to: [{type: 'person'}],
validation: (Rule) => Rule.required()
}
// Multiple references
{
name: 'categories',
title: 'Categories',
type: 'array',
of: [{type: 'reference', to: [{type: 'category'}]}]
}
// Reference with preview
{
name: 'relatedPosts',
title: 'Related Posts',
type: 'array',
of: [
{
type: 'reference',
to: [{type: 'blogPost'}],
options: {
filter: '_id != $id',
filterParams: {id: '_id'}
}
}
]
}
参照のクエリ
// Expand single reference
"author": author-> {
name,
"image": image.asset->url
}
// Expand array of references
"categories": categories[]-> {
_id,
title,
slug
}
// Filter by reference
*[_type == "blogPost" && references(*[_type == "category" && slug.current == $categorySlug]._id)]
パフォーマンスの最適化
クライアントの設定
// 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: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
perspective: 'published'
});
Next.js のキャッシュ
// On-demand revalidation
import { revalidateTag } from 'next/cache';
// Fetch with caching
const posts = await client.fetch(
query,
{},
{
cache: 'force-cache',
next: {
revalidate: 3600, // Revalidate every hour
tags: ['posts'] // Tag for on-demand revalidation
}
}
);
export async function POST(request: Request) {
revalidateTag('posts');
return Response.json({ revalidated: true });
}
GROQ クエリの最適化
// Use select() to limit fields
*[_type == "blogPost"]{
_id,
title,
slug,
publishedAt
}
// Avoid fetching large fields unless needed
*[_type == "blogPost"]{
..., // All fields
body // Exclude this for list views
}
// Use pagination
*[_type == "blogPost"] | order(publishedAt desc)[0...10]
// Limit reference depth
"author": author->{name} // Only fetch name, not entire document
Webhook とリアルタイム更新
Sanity Webhook の設定
Sanity ダッシュボードで Webhook を設定します。
- API → Webhooks に移動します
- Webhook URL を追加します:
https://yoursite.com/api/revalidate - データセットとイベント(作成、更新、削除)を選択します
Next.js の再検証エンドポイント
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-sanity-webhook-secret');
// Verify webhook secret
if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
// Revalidate based on document type
const { _type } = body;
if (_type === 'blogPost') {
revalidateTag('posts');
revalidatePath('/news');
}
if (_type === 'fixture') {
revalidateTag('fixtures');
revalidatePath('/fixtures');
}
return NextResponse.json({ revalidated: true });
}
ベストプラクティス
コンテンツモデリング
- スキーマの対象を絞る - 1 つのドキュメント型につき、対象となる関心事は 1 つにします
- 再利用可能なオブジェクトを使用する - SEO、ソーシャルリンクなど
- 説明を追加する - コンテンツ編集者がフィールドを理解できるようにします
- 適切なデフォルト値を設定する - 共通の値には
initialValueを使用します - 十分に検証する - 入力時に不正なデータを防ぎます
TypeScript
- スキーマ変更後に型を生成する - 型を常に同期させます
- 型ガードを使用する - 実行時にデータ構造を確認します
- クエリ結果に型を指定する - fetch 呼び出しに型注釈を追加します
パフォーマンス
- 画像には CDN を使用する - Sanity は自動的に CDN 経由で配信します
- ISR を実装する - コンテンツを最新に保つには Next.js の再検証を使用します
- クエリのフィールド数を制限する - 必要なデータだけを取得します
- 大規模なデータセットにはページネーションを使う - 一度にすべてを取得しないでください
セキュリティ
- トークンを公開しない - 環境変数を使用します
- Webhook のシークレットを検証する - 受信リクエストを確認します
- ユーザー入力をサニタイズする - CMS からの入力も対象です(Portable Text はデフォルトで安全です)
よくある落とし穴
❌ 避けること:
- 必要なフィールドが数個だけなのに、ドキュメント全体を取得する
- 計算で求められる値を保存する
- 深くネストしたスキーマを作成する(最大3~4階層)
- 単純な文字列フィールドで十分なのに、参照を使用する
- 画像の代替テキストを省略する
✅ 推奨事項:
- GROQ のプロジェクションを使用してフィールドを絞り込む
- 派生値をクエリまたはコンポーネントで計算する
- 可能な場合はスキーマをフラットに保つ
- コンテンツを共有・更新する必要がある場合にのみ参照する
- アクセシビリティのため、常に代替テキストを必須にする
クイックリファレンス
GROQ の構文
*[filter] | order(field direction)[range] {projection}
// Examples:
*[_type == "blogPost"] // All blog posts
*[_type == "blogPost" && featured] // Filtered
| order(publishedAt desc) // Sorted
[0...10] // Paginated
{title, slug, "image": mainImage.asset->url} // Projected
一般的なフィールド型
string, text, number, boolean, datetime, date
slug, url, email
image, file
array, object
reference
block (Portable Text)
検証方法
required(), min(), max(), length(), regex(), email(), url(), custom()
コメント
Markdown 対応