sanity-cms
Sanity CMS Skill
Integrate Sanity headless CMS for structured content management.
Architecture Overview
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Sanity Studio │────▶│ Sanity API │◀────│ Your Site │
│ (Editor UI) │ │ (Content DB) │ │ (Astro/11ty) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Editors Hosted Build-time fetch
Project Setup
Initialize Sanity
# Create new Sanity project
npm create sanity@latest -- --project-name my-project --dataset production
# Or add to existing project
npm install sanity @sanity/client
Project Structure
project/
├── sanity/ # Sanity Studio
│ ├── schemas/ # Content schemas
│ │ ├── index.js
│ │ ├── post.js
│ │ └── author.js
│ ├── sanity.config.js # Studio config
│ └── sanity.cli.js # CLI config
├── src/ # Your site
│ └── lib/
│ └── sanity.js # Client setup
└── package.json
Schema Definition
Basic Document Schema
// sanity/schemas/post.js
export default {
name: 'post',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().max(100),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
},
{
name: 'publishedAt',
title: 'Published At',
type: 'datetime',
},
{
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
},
{
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
title: 'Alt Text',
type: 'string',
validation: (Rule) => Rule.required(),
},
],
},
{
name: 'body',
title: 'Body',
type: 'array',
of: [
{ type: 'block' },
{ type: 'image' },
{ type: 'code' },
],
},
{
name: 'tags',
title: 'Tags',
type: 'array',
of: [{ type: 'string' }],
options: {
layout: 'tags',
},
},
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage',
},
prepare({ title, author, media }) {
return {
title,
subtitle: author ? `by ${author}` : '',
media,
};
},
},
};
Author Schema
// sanity/schemas/author.js
export default {
name: 'author',
title: 'Author',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'name' },
},
{
name: 'image',
title: 'Image',
type: 'image',
},
{
name: 'bio',
title: 'Bio',
type: 'text',
},
],
};
Register Schemas
// sanity/schemas/index.js
import post from './post';
import author from './author';
export const schemaTypes = [post, author];
// sanity/sanity.config.js
import { defineConfig } from 'sanity';
import { deskTool } from 'sanity/desk';
import { schemaTypes } from './schemas';
export default defineConfig({
name: 'default',
title: 'My Project',
projectId: 'your-project-id',
dataset: 'production',
plugins: [deskTool()],
schema: {
types: schemaTypes,
},
});
Client Setup
Sanity Client
// src/lib/sanity.js
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: true, // Use CDN for production
});
// For preview/draft content
export const previewClient = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_TOKEN, // Read token for drafts
});
Environment Variables
# .env
SANITY_PROJECT_ID=your-project-id
SANITY_DATASET=production
SANITY_TOKEN=your-read-token # For previews only
GROQ Queries
Basic Queries
// Fetch all published posts
const posts = await client.fetch(`
*[_type == "post" && !(_id in path("drafts.**"))] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
"author": author->name,
"mainImage": mainImage.asset->url
}
`);
// Fetch single post by slug
const post = await client.fetch(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
body,
publishedAt,
"author": author->{name, image, bio},
mainImage {
asset->{url, metadata},
alt
}
}
`, { slug: 'my-post-slug' });
Common Query Patterns
// Pagination
const page = 1;
const perPage = 10;
const posts = await client.fetch(`
*[_type == "post"] | order(publishedAt desc) [$start...$end] {
title,
"slug": slug.current
}
`, {
start: (page - 1) * perPage,
end: page * perPage,
});
// Filter by reference
const authorPosts = await client.fetch(`
*[_type == "post" && author._ref == $authorId] {
title,
"slug": slug.current
}
`, { authorId: 'author-id-here' });
// Search
const results = await client.fetch(`
*[_type == "post" && title match $query] {
title,
"slug": slug.current
}
`, { query: '*search*' });
// Count
const count = await client.fetch(`count(*[_type == "post"])`);
Astro Integration
Setup
npm install @sanity/astro @sanity/image-url
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sanity from '@sanity/astro';
export default defineConfig({
integrations: [
sanity({
projectId: 'your-project-id',
dataset: 'production',
useCdn: true,
}),
],
});
Fetching in Astro
---
// src/pages/blog/index.astro
import { sanityClient } from 'sanity:client';
const posts = await sanityClient.fetch(`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt
}
`);
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
Dynamic Routes
---
// src/pages/blog/[slug].astro
import { sanityClient } from 'sanity:client';
export async function getStaticPaths() {
const posts = await sanityClient.fetch(`
*[_type == "post"] { "slug": slug.current }
`);
return posts.map((post) => ({
params: { slug: post.slug },
}));
}
const { slug } = Astro.params;
const post = await sanityClient.fetch(`
*[_type == "post" && slug.current == $slug][0] {
title,
body,
publishedAt
}
`, { slug });
---
<article>
<h1>{post.title}</h1>
<!-- Render body -->
</article>
11ty Integration
Data File
// src/_data/posts.js
import { createClient } from '@sanity/client';
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: true,
});
export default async function() {
return client.fetch(`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
body
}
`);
}
Template
{# src/blog.njk #}
---
pagination:
data: posts
size: 1
alias: post
permalink: /blog/{{ post.slug }}/
---
<article>
<h1>{{ post.title }}</h1>
<time>{{ post.publishedAt | dateFormat }}</time>
<!-- Render body -->
</article>
Image Handling
Image URL Builder
// src/lib/image.js
import imageUrlBuilder from '@sanity/image-url';
import { client } from './sanity.js';
const builder = imageUrlBuilder(client);
export function urlFor(source) {
return builder.image(source);
}
Usage
import { urlFor } from '../lib/image.js';
// Get URL with transformations
const imageUrl = urlFor(post.mainImage)
.width(800)
.height(600)
.format('webp')
.url();
// Responsive images
const srcset = [400, 800, 1200]
.map((w) => `${urlFor(post.mainImage).width(w).url()} ${w}w`)
.join(', ');
Portable Text (Rich Content)
Rendering in Astro
---
import { PortableText } from '@portabletext/react';
const components = {
types: {
image: ({ value }) => (
<img src={urlFor(value).width(800).url()} alt={value.alt} />
),
code: ({ value }) => (
<pre><code class={`language-${value.language}`}>{value.code}</code></pre>
),
},
marks: {
link: ({ children, value }) => (
<a href={value.href} target="_blank" rel="noopener">{children}</a>
),
},
};
---
<PortableText value={post.body} components={components} />
Preview Mode
Astro Preview
// src/pages/api/preview.js
export async function GET({ request, cookies }) {
const url = new URL(request.url);
const secret = url.searchParams.get('secret');
const slug = url.searchParams.get('slug');
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid secret', { status: 401 });
}
cookies.set('preview', 'true', { path: '/' });
return Response.redirect(`/blog/${slug}`);
}
Deployment
Studio Hosting
# Deploy Sanity Studio
cd sanity
npx sanity deploy
API Configuration
- Go to sanity.io/manage
- Add CORS origins for your site
- Create API token for previews (read-only)
Checklist
When integrating Sanity:
- Schemas define all content types
- Validation rules on required fields
- Client uses CDN in production
- Environment variables configured
- CORS origins set in Sanity dashboard
- Images use URL builder
- Portable Text has custom components
- Preview mode works (if needed)
Related Skills
- astro - Astro integration patterns
- eleventy - 11ty data file patterns
- env-config - Environment variables
- deployment - Deploying with Sanity
More from profpowell/vanilla-breeze
api-client
Fetch API patterns with error handling, retry logic, and caching. Use when building API integrations, handling network failures, or implementing offline-first data fetching.
44validation
Validate data with JSON Schema and AJV. Use when validating API requests, form submissions, database inputs, or any data boundaries. Provides deterministic validation with consistent error formats.
43xhtml-author
Write valid XHTML-strict HTML5 markup. Use when creating HTML files, editing markup, building web pages, or writing any HTML content. Ensures semantic structure and XHTML syntax.
10layout-grid
Design-focused grid layout system with fluid scaling, responsive columns, and resolution-independent patterns. Use when creating page layouts, card grids, or multi-column designs.
8git-workflow
Enforce structured git workflow with conventional commits, feature branches, semver versioning, and work logging. Use for all code changes to prevent work loss and maintain history.
8ci-cd
Configure GitHub Actions for automated testing, building, and deployment. Use when setting up CI/CD pipelines, automating releases, or managing deployment workflows.
7