--- name: sanity-cms description: A headless CMS that provides a flexible content model and powerful APIs. Use for structured content management with type-safe queries for Williamstown SC. --- # sanity-cms ## Instructions Follow documentation from https://www.sanity.io/learn/llms.txt to implement Sanity CMS in the project. This skill provides project-specific patterns for sports club content modeling, TypeScript integration, and Next.js optimization. ## Content Modeling for Sports Clubs ### Core Schema Types The Williamstown SC website requires these primary content types: 1. **blogPost** - Club news, announcements, match reports 2. **event** - Matches, training sessions, club events 3. **player** - Team roster and player profiles 4. **fixture** - Match schedule, results, and statistics 5. **sponsor** - Club sponsors and partners 6. **page** - Static pages (About, Contact, etc.) 7. **teamMember** - Coaching staff and committee members ## Schema Best Practices ### Naming Conventions Follow these conventions for consistency: ```typescript // Schema files: camelCase.ts blogPost.ts; teamMember.ts; fixtureResult.ts; // Field names: camelCase publishedAt; featuredImage; homeTeamScore; // Document types: camelCase blogPost; teamMember; fixtureResult; ``` ### Required Fields Pattern Every document type should include these base fields: ```typescript { 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 Metadata Pattern Reusable SEO object for all content types: ```typescript // 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' } ``` ## Example Document Schemas ### Blog Post Schema ```typescript // 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' } } }; ``` ### Fixture/Match Result Schema ```typescript // 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() }; } } }; ``` ### Player Profile Schema ```typescript // 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 Integration ### Generate Types Add to your `package.json`: ```json { "scripts": { "sanity:typegen": "sanity schema extract && sanity typegen generate" } } ``` Run after schema changes: ```bash npm run sanity:typegen ``` ### Use Generated Types ```typescript // 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 (

{post.title}

{post.excerpt}

) } ``` ### Type-safe GROQ Queries ```typescript 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(query); ``` ## GROQ Query Patterns ### Common Queries #### Latest Blog Posts ```groq *[_type == "blogPost"] | order(publishedAt desc)[0...10] { _id, title, slug, excerpt, publishedAt, "image": mainImage.asset->url, "imageAlt": mainImage.alt, "categories": categories[]->title, featured } ``` #### Upcoming Fixtures ```groq *[_type == "fixture" && matchDate > now()] | order(matchDate asc) { _id, matchDate, homeTeam, awayTeam, venue, competition, isHomeGame } ``` #### Past Results ```groq *[_type == "fixture" && matchDate < now() && defined(homeScore)] | order(matchDate desc)[0...10] { _id, matchDate, homeTeam, awayTeam, homeScore, awayScore, venue, isHomeGame } ``` #### Team Roster by Position ```groq *[_type == "player"] | order(position asc, number asc) { _id, name, number, position, "photo": photo.asset->url, "photoAlt": photo.alt, stats } ``` #### Single Post with Full Content ```groq *[_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 } ``` ### Reference Expansion ```groq // 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 } ``` ### Filtering & Sorting ```groq // 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 ``` ### Pagination ```groq // 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] ``` ## Image Optimization ### Image Schema with Validation ```typescript { 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() } ``` ### Image URL Builder ```typescript // 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 Image Integration ```tsx import Image from 'next/image'; import { urlFor } from '@/lib/sanity/image'; {post.mainImage.alt}; ``` ### Responsive Images ```typescript // 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: {image.alt} ``` ## Portable Text (Rich Text) ### Schema Configuration ```typescript { 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' } ] } ] } ``` ### Rendering Portable Text ```tsx import {PortableText} from '@portabletext/react' import Image from 'next/image' import {urlFor} from '@/lib/sanity/image' const components = { block: { h1: ({children}) => (

{children}

), h2: ({children}) => (

{children}

), h3: ({children}) => (

{children}

), normal: ({children}) => (

{children}

), }, marks: { link: ({children, value}) => ( {children} ), }, types: { image: ({value}) => (
{value.alt {value.caption && (
{value.caption}
)}
), }, } // Usage: ``` ## Preview & Draft Mode ### Enable Draft Mode in Next.js ```typescript // 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 || '/'); } ``` ### Disable Draft Mode ```typescript // app/api/exit-draft/route.ts import { draftMode } from 'next/headers'; import { redirect } from 'next/navigation'; export async function GET() { draftMode().disable(); redirect('/'); } ``` ### Fetch with Draft Content ```typescript 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; } ``` ## Schema Organization ### Recommended Directory Structure ``` 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 ``` ### Schema Index File ```typescript // 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 ]; ``` ## Validation Patterns ### Common Validations ```typescript // 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; }); ``` ## Content Relationships ### References ```typescript // 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'} } } ] } ``` ### Querying References ```groq // 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)] ``` ## Performance Optimization ### Client Configuration ```typescript // 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 Caching ```typescript // 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 Query Optimization ```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 ``` ## Webhooks & Real-time Updates ### Sanity Webhook Setup Configure webhooks in Sanity dashboard: 1. Go to API → Webhooks 2. Add webhook URL: `https://yoursite.com/api/revalidate` 3. Select dataset and events (create, update, delete) ### Next.js Revalidation Endpoint ```typescript // 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 }); } ``` ## Best Practices ### Content Modeling 1. **Keep schemas focused** - One document type per concern 2. **Use objects for reusability** - SEO, social links, etc. 3. **Add descriptions** - Help content editors understand fields 4. **Set sensible defaults** - Use `initialValue` for common values 5. **Validate thoroughly** - Prevent bad data at input time ### TypeScript 1. **Generate types after schema changes** - Keep types in sync 2. **Use type guards** - Verify data structure at runtime 3. **Type query results** - Add type annotations to fetch calls ### Performance 1. **Use CDN for images** - Sanity automatically serves via CDN 2. **Implement ISR** - Use Next.js revalidation for fresh content 3. **Limit query fields** - Only fetch what you need 4. **Paginate large datasets** - Don't fetch everything at once ### Security 1. **Never expose tokens** - Use environment variables 2. **Validate webhook secrets** - Verify incoming requests 3. **Sanitize user input** - Even from CMS (Portable Text is safe by default) ## Common Pitfalls ❌ **Don't:** - Fetch entire documents when you only need a few fields - Store computed values that can be calculated - Create deeply nested schemas (max 3-4 levels) - Use references when a simple string field would work - Skip alt text on images ✅ **Do:** - Use GROQ projections to limit fields - Calculate derived values in queries or components - Keep schemas flat when possible - Reference only when you need to share/update content - Always require alt text for accessibility ## Quick Reference ### GROQ Syntax ```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 ``` ### Common Field Types ``` string, text, number, boolean, datetime, date slug, url, email image, file array, object reference block (Portable Text) ``` ### Validation Methods ``` required(), min(), max(), length(), regex(), email(), url(), custom() ```