Sanity
Build with Sanity CMS for structured content management. Use this skill when the project needs a headless CMS with real-time collaboration, GROQ queries, Portable Text rendering, image pipelines, or Next.js integration. Covers schema definitions, content modeling, Studio customization, and webhooks.
You are a content platform specialist who integrates Sanity into projects.
Sanity is a structured content platform with a real-time datastore, a
customizable editing environment (Sanity Studio), and a powerful query language
called GROQ. Content is treated as structured data, not pages.
## Key Points
- Define schemas with `defineType` and `defineField` for full TypeScript inference
- Use `groq` tagged template literals — they enable syntax highlighting and tooling
- Set `apiVersion` to a fixed date so API behavior is predictable
- Use `useCdn: true` for public reads and `useCdn: false` for draft/preview
- Add `hotspot: true` to image fields so editors can control crop focus points
- Always require `alt` text on image fields for accessibility
- Use references instead of embedding data to keep documents normalized
- Use `next: { tags: [...] }` with `revalidateTag` for on-demand ISR via webhooks
- Fetching all document fields when you only need a few — GROQ projections exist for this
- Using the REST API directly instead of GROQ — GROQ is more efficient and flexible
- Storing presentation logic in content schemas — keep content structure separate from layout
- Embedding large data blobs inside documents — use references for related content
## Quick Example
```bash
# Create a new Sanity project
npm create sanity@latest
# Or add to an existing project
npm install sanity @sanity/client @sanity/image-url next-sanity
```
```env
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=sk...
SANITY_WEBHOOK_SECRET=your-webhook-secret
```skilldb get cms-services-skills/sanityFull skill: 321 linesSanity CMS Integration
You are a content platform specialist who integrates Sanity into projects. Sanity is a structured content platform with a real-time datastore, a customizable editing environment (Sanity Studio), and a powerful query language called GROQ. Content is treated as structured data, not pages.
Core Philosophy
Content as structured data
Sanity stores content as JSON documents with explicit schemas. Every field is typed and queryable. This means your content can be rendered anywhere: web, mobile, email, IoT. Never model content around a single presentation — model it around meaning.
Real-time by default
Every edit in Sanity Studio is synced in real-time via the Listener API. Multiple editors can work on the same document simultaneously with live presence indicators. Drafts and published versions are separate documents.
GROQ over REST
GROQ (Graph-Relational Object Queries) is Sanity's query language. It is more expressive than REST endpoints and more concise than GraphQL for content queries. Use GROQ as the default approach.
Setup
Install
# Create a new Sanity project
npm create sanity@latest
# Or add to an existing project
npm install sanity @sanity/client @sanity/image-url next-sanity
Environment variables
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=sk...
SANITY_WEBHOOK_SECRET=your-webhook-secret
Client configuration
// lib/sanity.client.ts
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: true, // false for authenticated or draft requests
});
// Preview client for draft content
export const previewClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_API_TOKEN,
perspective: 'previewDrafts',
});
Key Techniques
Schema definitions
// schemas/post.ts
import { defineType, defineField } from 'sanity';
export const postType = defineType({
name: 'post',
title: 'Blog Post',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
validation: (rule) => rule.required().max(120),
}),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }],
}),
defineField({
name: 'mainImage',
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative text',
validation: (rule) => rule.required(),
}),
],
}),
defineField({
name: 'body',
type: 'blockContent', // Portable Text
}),
defineField({
name: 'publishedAt',
type: 'datetime',
}),
],
preview: {
select: { title: 'title', author: 'author.name', media: 'mainImage' },
prepare({ title, author, media }) {
return { title, subtitle: author ?? 'No author', media };
},
},
});
GROQ queries
// lib/sanity.queries.ts
import { groq } from 'next-sanity';
export const postsQuery = groq`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
"author": author->{ name, image },
"mainImage": mainImage{ asset->{ url, metadata { dimensions } }, alt },
"excerpt": array::join(string::split(pt::text(body), "")[0..200], "") + "..."
}
`;
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
body,
publishedAt,
"author": author->{ name, image, bio },
"mainImage": mainImage{ asset->{ url, metadata }, alt },
"related": *[_type == "post" && _id != ^._id && count(categories[@._ref in ^.^.categories[]._ref]) > 0][0..2] {
title, slug, mainImage
}
}
`;
Data fetching in Next.js App Router
// app/blog/page.tsx
import { client } from '@/lib/sanity.client';
import { postsQuery } from '@/lib/sanity.queries';
export default async function BlogPage() {
const posts = await client.fetch(postsQuery, {}, {
next: { tags: ['posts'] },
});
return (
<ul>
{posts.map((post: any) => (
<li key={post._id}>
<a href={`/blog/${post.slug.current}`}>{post.title}</a>
</li>
))}
</ul>
);
}
Portable Text rendering
// components/PortableTextRenderer.tsx
import { PortableText, type PortableTextComponents } from '@portabletext/react';
import Image from 'next/image';
import { urlFor } from '@/lib/sanity.image';
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<figure>
<Image
src={urlFor(value).width(800).auto('format').url()}
alt={value.alt || ''}
width={800}
height={450}
/>
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
),
code: ({ value }) => (
<pre data-language={value.language}>
<code>{value.code}</code>
</pre>
),
},
marks: {
link: ({ children, value }) => (
<a href={value.href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
internalLink: ({ children, value }) => (
<a href={`/${value.slug}`}>{children}</a>
),
},
};
export function Body({ value }: { value: any }) {
return <PortableText value={value} components={components} />;
}
Image pipeline
// lib/sanity.image.ts
import imageUrlBuilder from '@sanity/image-url';
import { client } from './sanity.client';
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
// Usage with responsive images
// urlFor(image).width(400).height(300).fit('crop').auto('format').url()
// urlFor(image).width(800).blur(50).url() // placeholder
Webhook-triggered revalidation
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { parseBody } from 'next-sanity/webhook';
export async function POST(req: Request) {
const { isValidSignature, body } = await parseBody<{
_type: string;
slug?: { current: string };
}>(req, process.env.SANITY_WEBHOOK_SECRET);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (body?._type === 'post') {
revalidateTag('posts');
}
return Response.json({ revalidated: true, now: Date.now() });
}
Real-time preview with Live Content API
// app/blog/[slug]/page.tsx
import { defineLive } from 'next-sanity';
import { client } from '@/lib/sanity.client';
const { sanityFetch, SanityLive } = defineLive({ client });
export default async function PostPage({ params }: { params: { slug: string } }) {
const { data: post } = await sanityFetch({
query: postBySlugQuery,
params: { slug: params.slug },
});
return (
<>
<article>
<h1>{post.title}</h1>
<Body value={post.body} />
</article>
<SanityLive />
</>
);
}
Best Practices
- Define schemas with
defineTypeanddefineFieldfor full TypeScript inference - Use
groqtagged template literals — they enable syntax highlighting and tooling - Set
apiVersionto a fixed date so API behavior is predictable - Use
useCdn: truefor public reads anduseCdn: falsefor draft/preview - Add
hotspot: trueto image fields so editors can control crop focus points - Always require
alttext on image fields for accessibility - Use references instead of embedding data to keep documents normalized
- Use
next: { tags: [...] }withrevalidateTagfor on-demand ISR via webhooks
Anti-Patterns
- Fetching all document fields when you only need a few — GROQ projections exist for this
- Using the REST API directly instead of GROQ — GROQ is more efficient and flexible
- Storing presentation logic in content schemas — keep content structure separate from layout
- Embedding large data blobs inside documents — use references for related content
- Skipping webhook signature validation — always verify
SANITY_WEBHOOK_SECRET - Using
useCdn: falseeverywhere — CDN reads are faster and cheaper for public content - Not setting an
apiVersion— unversioned queries break when the API changes
Install this skill directly: skilldb add cms-services-skills
Related Skills
Storyblok
Build with Storyblok as a visual headless CMS. Use this skill when the project needs a component-based CMS with a real-time visual editor, nested content blocks, localization, asset management, or framework integration (Next.js, Nuxt, Astro). Covers space setup, content modeling with bloks, the Storyblok Bridge, and webhook-driven revalidation.
Strapi
Build with Strapi for self-hosted headless CMS. Use this skill when the project needs Strapi integration for content types, REST or GraphQL APIs, custom controllers, plugin development, admin panel customization, or authentication. Covers Strapi v5 patterns and deployment.
Tina
Tina is an open-source, Git-backed headless CMS that enables developers to build powerful content-editing experiences directly within their code. Use Tina when you need a local-first CMS for modern web applications, integrating content directly into your Git workflow and leveraging GraphQL for data fetching.
Builder Io
Builder.io is a Visual Headless CMS and API that empowers developers to integrate a drag-and-drop page builder and content management system into any web application. Use this skill when you need a powerful visual editor for marketing and content teams, A/B testing, personalization, and a flexible content API for dynamic web experiences.
Caisy
caisy is a headless CMS designed for speed and scalability, empowering developers to deliver exceptional digital experiences. Use caisy when you need a performant, API-first content platform with strong developer tools, a focus on speed, and a GraphQL-native approach.
Contentful
Build with Contentful for headless content management. Use this skill when the project needs Contentful integration for content models, REST or GraphQL APIs, rich text rendering, localization, webhooks, environment management, or Next.js SDK usage. Covers the full Contentful developer workflow.