| name | sanity |
| description | 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. |
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]