Skip to main content
Technology & EngineeringCms Services322 lines

Sanity

Build with Sanity CMS for structured content management. Use this skill when

Quick Summary38 lines
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 lines
Paste into your CLAUDE.md or agent config

Sanity 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 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

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: false everywhere — 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

Get CLI access →