Sanity
Build with Sanity CMS for structured content management. Use this skill when
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: 322 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
Builder Io
Builder.io is a Visual Headless CMS and API that empowers developers to integrate
Caisy
caisy is a headless CMS designed for speed and scalability, empowering developers
Contentful
Build with Contentful for headless content management. Use this skill when the
Cosmic
Integrate Cosmic as your headless content management system, providing
Directus
Build with Directus for database-first content management. Use this skill
Ghost
Integrate Ghost as a powerful headless CMS or a full-featured publishing platform.