sanity
Manages structured content with Sanity headless CMS using GROQ queries and real-time collaboration. Use when building content-driven sites with customizable schemas, live previews, and flexible data modeling.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Sanity CMS
Headless CMS with GROQ query language, real-time collaboration, and customizable Studio. Content as structured data with full TypeScript support.
Quick Start
npm create sanity@latest
Follow prompts to create a project. This sets up:
- Sanity Studio (admin UI)
- Content schema
- API access
Client Setup
npm install @sanity/client
import { createClient } from '@sanity/client';
const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: true, // false for real-time data
});
GROQ Queries
Graph-Relational Object Queries - Sanity's query language.
Basic Query
// Get all documents of a type
const posts = await client.fetch(`*[_type == "post"]`);
// With projection (select fields)
const posts = await client.fetch(`
*[_type == "post"] {
_id,
title,
slug,
publishedAt
}
`);
Filtering
// Type matching
*[_type == "post"]
// Equality
*[_type == "post" && slug.current == "hello-world"]
// Comparisons
*[_type == "post" && publishedAt >= "2024-01-01"]
// Boolean operators
*[_type == "post" && (featured == true || priority > 5)]
// Text matching (with wildcard)
*[_type == "post" && title match "Next*"]
// Array membership
*[_type == "post" && "typescript" in tags]
// Field existence
*[_type == "post" && defined(featuredImage)]
// References
*[_type == "post" && references("author-id")]
Projections
// Select specific fields
*[_type == "post"] {
_id,
title,
"slug": slug.current
}
// Rename fields
*[_type == "post"] {
"postTitle": title,
"url": slug.current
}
// Include all fields plus extras
*[_type == "post"] {
...,
"url": slug.current
}
// Expand references
*[_type == "post"] {
title,
author-> // Full author document
}
*[_type == "post"] {
title,
"authorName": author->name, // Just the name
"authorImage": author->image.asset->url
}
// Array of references
*[_type == "post"] {
title,
"categories": categories[]->title
}
Ordering & Pagination
// Order ascending
*[_type == "post"] | order(publishedAt asc)
// Order descending
*[_type == "post"] | order(publishedAt desc)
// Multiple sort fields
*[_type == "post"] | order(featured desc, publishedAt desc)
// Pagination (0-based index)
*[_type == "post"] | order(publishedAt desc)[0...10] // First 10
// Skip and take
*[_type == "post"] | order(publishedAt desc)[10...20] // Next 10
// Single item
*[_type == "post" && slug.current == $slug][0]
Joins & References
// Get posts with their authors
*[_type == "post"] {
title,
author->{
name,
image
}
}
// Reverse reference (get author's posts)
*[_type == "author"] {
name,
"posts": *[_type == "post" && references(^._id)] {
title,
slug
}
}
// Count references
*[_type == "author"] {
name,
"postCount": count(*[_type == "post" && references(^._id)])
}
Parameters
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug: 'hello-world' }
);
const posts = await client.fetch(
`*[_type == "post" && publishedAt > $date] | order(publishedAt desc)[0...$limit]`,
{ date: '2024-01-01', limit: 10 }
);
Schema Definition
Define content types in code.
// schemas/post.ts
import { defineType, defineField } from 'sanity';
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required()
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96
}
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }]
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime'
}),
defineField({
name: 'body',
title: 'Body',
type: 'blockContent' // Portable Text
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }]
})
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage'
}
}
});
Register Schema
// sanity.config.ts
import { defineConfig } from 'sanity';
import { deskTool } from 'sanity/desk';
import { post } from './schemas/post';
import { author } from './schemas/author';
export default defineConfig({
name: 'default',
title: 'My CMS',
projectId: 'your-project-id',
dataset: 'production',
plugins: [deskTool()],
schema: {
types: [post, author]
}
});
Portable Text (Rich Text)
// schemas/blockContent.ts
import { defineType, defineArrayMember } from 'sanity';
export const blockContent = defineType({
name: 'blockContent',
title: 'Block Content',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' }
],
marks: {
decorators: [
{ title: 'Bold', value: 'strong' },
{ title: 'Italic', value: 'em' },
{ title: 'Code', value: 'code' }
],
annotations: [
{
name: 'link',
type: 'object',
fields: [{ name: 'href', type: 'url' }]
}
]
}
}),
defineArrayMember({
type: 'image',
options: { hotspot: true }
})
]
});
Render Portable Text
npm install @portabletext/react
import { PortableText } from '@portabletext/react';
const components = {
types: {
image: ({ value }) => (
<img src={urlFor(value).url()} alt={value.alt} />
)
},
marks: {
link: ({ children, value }) => (
<a href={value.href}>{children}</a>
)
}
};
function PostBody({ body }) {
return <PortableText value={body} components={components} />;
}
Image Handling
npm install @sanity/image-url
import imageUrlBuilder from '@sanity/image-url';
const builder = imageUrlBuilder(client);
function urlFor(source) {
return builder.image(source);
}
// Usage
const imageUrl = urlFor(post.mainImage)
.width(800)
.height(600)
.fit('crop')
.url();
// Responsive images
const srcSet = [400, 800, 1200].map(
(w) => `${urlFor(post.mainImage).width(w).url()} ${w}w`
).join(', ');
TypeScript + Type Generation
npm install -D sanity-typegen
# Generate types from schema
npx sanity typegen generate
import { defineQuery } from 'groq';
import type { Post } from './sanity.types';
const POSTS_QUERY = defineQuery(`*[_type == "post"] {
_id,
title,
slug,
author->
}`);
// Fully typed response
const posts = await client.fetch<Post[]>(POSTS_QUERY);
Real-Time Updates
// Listen to changes
const subscription = client
.listen(`*[_type == "post"]`)
.subscribe((update) => {
console.log('Change:', update);
// { transition: 'update', documentId: '...', result: {...} }
});
// Stop listening
subscription.unsubscribe();
Next.js Integration
// lib/sanity.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',
});
// app/posts/[slug]/page.tsx
import { client } from '@/lib/sanity';
export async function generateStaticParams() {
const slugs = await client.fetch(`*[_type == "post"].slug.current`);
return slugs.map((slug: string) => ({ slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0] {
title,
body,
author->{ name }
}`,
{ slug: params.slug }
);
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<PortableText value={post.body} />
</article>
);
}
Live Preview
// Enable preview mode
import { definePreview } from 'next-sanity/preview';
const usePreview = definePreview({
projectId: 'your-project-id',
dataset: 'production',
});
// In component
function PostPreview({ slug }) {
const post = usePreview(null, QUERY, { slug });
return <Post post={post} />;
}
Common GROQ Patterns
Get by Slug
*[_type == "post" && slug.current == $slug][0]
Paginated List
{
"items": *[_type == "post"] | order(publishedAt desc)[$start...$end],
"total": count(*[_type == "post"])
}
Related Posts
*[_type == "post" && _id != $currentId && count((categories[]._ref)[@ in $categoryIds]) > 0][0...4]
Full-Text Search
*[_type == "post" && [title, body[].children[].text] match $query]
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-28
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!