api-cms-sanity
Structured content platform — GROQ queries, schema definitions, @sanity/client, Portable Text, image handling, real-time listeners, mutations, TypeGen
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-cms-sanity/skills/api-cms-sanity
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Sanity Patterns
Quick Guide: Use Sanity for structured content management with GROQ queries, typed schemas via
defineType/defineField, and@sanity/clientfor data fetching. Always setapiVersionto a dated string, useuseCdn: truefor public reads, handle draft documents explicitly, use@sanity/image-urlfor image transformations, and render rich text with@portabletext/react. Generate TypeScript types withsanity typegen generate.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST always set apiVersion on createClient to a dated string like '2025-02-19' — omitting it uses a legacy API that may break)
(You MUST use useCdn: true for public read queries and useCdn: false when using a token or needing fresh data)
(You MUST use parameterized GROQ queries ($param) for any dynamic values — never interpolate user input into GROQ strings)
(You MUST handle drafts explicitly — draft documents have _id prefixed with drafts. and are not returned by default with perspective: 'published')
(You MUST use defineQuery() from groq and assign queries to named variables for TypeGen type generation)
</critical_requirements>
Auto-detection: Sanity, sanity, @sanity/client, createClient, GROQ, groq, defineType, defineField, defineArrayMember, @sanity/image-url, urlFor, @portabletext/react, PortableText, portable text, block content, sanity.config, sanity.cli, typegen, sanity studio, content lake
When to use:
- Setting up
@sanity/clientwithcreateClientfor data fetching - Writing GROQ queries (filters, projections, joins, ordering, slicing)
- Defining content schemas with
defineType,defineField,defineArrayMember - Rendering Portable Text (block content) with
@portabletext/react - Generating image URLs with
@sanity/image-url(responsive images, crops, hotspots) - Performing mutations (create, patch, delete, transactions)
- Setting up real-time listeners with
client.listen() - Generating TypeScript types with Sanity TypeGen
Key patterns covered:
- Client setup with
createClientandapiVersionconfiguration - GROQ query language: filters, projections, ordering, slicing, joins, references
- Schema definitions: document types, object types, arrays, references, images
- Portable Text rendering with custom components
- Image URL builder with responsive images and transformations
- Mutations: create, createOrReplace, patch, delete, transactions
- Real-time listeners via
client.listen() - TypeGen for type-safe GROQ queries with
defineQuery()
When NOT to use:
- GraphQL-only APIs (Sanity supports GROQ primarily; use GraphQL skill if needed)
- Direct database access (Sanity is a hosted content lake, not a database)
- Non-Sanity CMS platforms (use the dedicated skill for your CMS)
Detailed Resources:
- For decision frameworks and quick-reference tables, see reference.md
Client & GROQ:
- examples/core.md — Client setup, GROQ queries, error handling, TypeGen
Schemas:
- examples/schemas.md — defineType, defineField, document types, object types, references, images
Rich Content:
- examples/rich-content.md — Portable Text rendering, image URL builder, responsive images
Mutations & Real-time:
- examples/mutations.md — Create, patch, delete, transactions, real-time listeners
<decision_framework>
Decision Framework
useCdn: true vs false
Is the data public and non-personalized?
├─ YES → useCdn: true (edge-cached, fast)
└─ NO →
├─ Using a token for authenticated reads? → useCdn: false
├─ Need real-time fresh data (preview)? → useCdn: false
└─ Performing mutations? → useCdn: false
Draft Handling
Do you need draft documents?
├─ YES → Use perspective: 'previewDrafts' (requires token)
├─ NO → Use perspective: 'published' (default since API v2025-02-19)
└─ Mixed (preview mode toggle)?
└─ Create two clients: one public (useCdn: true), one preview (token + useCdn: false)
GROQ Query Return Shape
How many documents do you expect?
├─ One (by ID, slug, singleton) → Add [0] at end (returns object or null)
├─ Many (list, feed) → No slice suffix (returns array)
└─ Paginated → Add [start...end] slice
Image Handling
How should images be delivered?
├─ Fixed size (thumbnails, avatars) → urlFor(img).width(W).height(H).url()
├─ Responsive (article images) → srcSet with multiple widths
├─ Format optimization → .auto('format') for WebP/AVIF
└─ Cropped to aspect ratio → .width(W).height(H).fit('crop')
Mutation Method Selection
What operation do you need?
├─ Create new document → client.create()
├─ Create or fully replace → client.createOrReplace() (for singletons)
├─ Create only if missing → client.createIfNotExists()
├─ Update specific fields → client.patch(id).set({...}).commit()
├─ Remove fields → client.patch(id).unset([...]).commit()
├─ Multiple related changes → client.transaction()...commit()
└─ Delete document → client.delete(id)
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Missing
apiVersionon client — OmittingapiVersionuses legacy API behavior that may change without notice. Always pin to a date string. - String interpolation in GROQ queries — Interpolating user input into GROQ strings enables GROQ injection. Always use
$paramparameters. - Using
useCdn: truewith a token — CDN-cached responses ignore authentication tokens. Authenticated queries must useuseCdn: false. - Accessing draft documents without a token — Drafts (
drafts.*documents) require an API token andperspective: 'previewDrafts'. Without a token, drafts are invisible.
Medium Priority Issues:
- Fetching all fields with
{...}when only a few are needed — Over-fetching wastes bandwidth and CDN cache efficiency. Project only the fields you need. - Missing
.commit()on patches —client.patch(id).set({...})without.commit()does nothing — the mutation is never sent. - Not using
defineQuery()for GROQ queries — TypeGen cannot generate types for queries that aren't wrapped indefineQuery()or assigned to named variables. - Hardcoded project ID or dataset — Use environment variables; hardcoded values prevent environment switching and leak project details.
- Using deprecated
@sanity/block-content-to-react— Replaced by@portabletext/react. The old package is unmaintained.
Common Mistakes:
- Forgetting
_keyon array items in mutations — Every item in a Sanity array must have a unique_keyfield. Mutations without_keywill fail. - Using
_idwithclient.create()—create()generates a random_id. If you specify_idand the document exists, it errors. UsecreateOrReplace()orcreateIfNotExists()for idempotent operations. - Not handling the case where
[0]returnsnull— A GROQ query ending in[0]returnsnullif no documents match, not an empty object. - Expecting
client.listen()to work with projections — The listener only uses the filter portion of a GROQ query. Projections, ordering, and slicing are ignored.
Gotchas & Edge Cases:
- API version
2025-02-19changed default perspective — Before this version, the default perspective wasraw(includes drafts). After, it defaults topublished. Existing code may break if you updateapiVersionwithout accounting for this. - CDN cache is eventual — After a mutation, CDN-cached queries (
useCdn: true) may return stale data for a few seconds. UseuseCdn: falseor add a small delay for consistency-critical reads after writes. - Slug fields store value in
.current— Queryslug.current, notslugdirectly.*[slug == "my-slug"]will never match. - Image fields require the full object for crop/hotspot — Passing only
asset._reftourlFor()works for basic URLs but loses crop and hotspot metadata. Pass the entire image field object. - Portable Text arrays need
_keyon every block — When creating Portable Text content programmatically, every block and inline object needs a unique_key. client.listen()reconnects automatically — But there's no built-in guarantee against missed events during reconnection. For critical use cases, combine with periodic re-fetching.- TypeGen requires
schema.jsonextraction first — Runnpx sanity schema extractbeforenpx sanity typegen generate. The extract step reads your Studio schemas and outputs a JSON representation.
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST always set apiVersion on createClient to a dated string like '2025-02-19' — omitting it uses a legacy API that may break)
(You MUST use useCdn: true for public read queries and useCdn: false when using a token or needing fresh data)
(You MUST use parameterized GROQ queries ($param) for any dynamic values — never interpolate user input into GROQ strings)
(You MUST handle drafts explicitly — draft documents have _id prefixed with drafts. and are not returned by default with perspective: 'published')
(You MUST use defineQuery() from groq and assign queries to named variables for TypeGen type generation)
Failure to follow these rules will cause unpredictable API behavior, GROQ injection vulnerabilities, and untyped query results.
</critical_reminders>
Files (skills)
-
examples
-
core.md 10.2 KB
# Sanity Core Examples > Client setup, GROQ queries, error handling, and TypeGen patterns. See [SKILL.md](../SKILL.md) for core concepts. **Schemas:** See [schemas.md](schemas.md). **Rich content:** See [rich-content.md](rich-content.md). **Mutations:** See [mutations.md](mutations.md). --- ## Pattern 1: Client Setup — Public Reads ### Good Example — Typed Client with API Version ```typescript // lib/sanity-client.ts import { createClient } from "@sanity/client"; const PROJECT_ID = process.env.SANITY_PROJECT_ID!; const DATASET = process.env.SANITY_DATASET!; const API_VERSION = "2025-02-19"; export const client = createClient({ projectId: PROJECT_ID, dataset: DATASET, apiVersion: API_VERSION, useCdn: true, // Edge-cached for public reads }); ``` **Why good:** `apiVersion` pins API behavior to a known date, `useCdn: true` serves cached responses from CDN edge, named constants for config values, named export ### Bad Example — Missing API Version ```typescript import { createClient } from "@sanity/client"; // BAD: No apiVersion, hardcoded values const client = createClient({ projectId: "abc123", dataset: "production", }); ``` **Why bad:** Missing `apiVersion` uses legacy API behavior that may change without warning, hardcoded project ID prevents environment switching, no `useCdn` defaults to `false` (always hitting origin) --- ## Pattern 2: Client Setup — Authenticated / Preview ### Good Example — Token-Based Client for Preview Mode ```typescript // lib/sanity-preview-client.ts import { createClient } from "@sanity/client"; const PROJECT_ID = process.env.SANITY_PROJECT_ID!; const DATASET = process.env.SANITY_DATASET!; const API_VERSION = "2025-02-19"; const API_TOKEN = process.env.SANITY_API_TOKEN!; // Preview client: sees drafts, no CDN cache export const previewClient = createClient({ projectId: PROJECT_ID, dataset: DATASET, apiVersion: API_VERSION, useCdn: false, // Must be false when using token token: API_TOKEN, perspective: "previewDrafts", // Include draft documents }); ``` **Why good:** `useCdn: false` required with tokens (CDN ignores auth), `perspective: 'previewDrafts'` returns draft documents overlaid on published, server-only token kept in environment variable ### Good Example — Dual Client Pattern ```typescript // lib/sanity-clients.ts import { createClient } from "@sanity/client"; const PROJECT_ID = process.env.SANITY_PROJECT_ID!; const DATASET = process.env.SANITY_DATASET!; const API_VERSION = "2025-02-19"; // Public reads — CDN cached export const client = createClient({ projectId: PROJECT_ID, dataset: DATASET, apiVersion: API_VERSION, useCdn: true, }); // Preview reads — fresh, includes drafts (server-only) export const previewClient = createClient({ projectId: PROJECT_ID, dataset: DATASET, apiVersion: API_VERSION, useCdn: false, token: process.env.SANITY_API_TOKEN!, perspective: "previewDrafts", }); // Choose client based on preview mode export function getClient(preview = false) { return preview ? previewClient : client; } ``` **Why good:** Separate clients for public and preview, factory function selects the right client, preview token never sent to CDN, shared config for consistency --- ## Pattern 3: GROQ Queries — Filters and Projections ### Good Example — Parameterized Query with defineQuery ```typescript import { defineQuery } from "groq"; // List query: multiple documents const POSTS_QUERY = defineQuery(` *[_type == "post" && published == true]{ _id, title, "slug": slug.current, "authorName": author->name, "excerpt": pt::text(body)[0...200], publishedAt } | order(publishedAt desc) [0...10] `); // Detail query: single document by slug const POST_BY_SLUG_QUERY = defineQuery(` *[_type == "post" && slug.current == $slug][0]{ _id, title, body, publishedAt, "author": author->{ name, "imageUrl": image.asset->url }, "categories": categories[]->{ _id, title }, "relatedPosts": *[ _type == "post" && _id != ^._id && count(categories[@._ref in ^.^.categories[]._ref]) > 0 ][0...3]{ _id, title, "slug": slug.current } } `); // Fetch with parameters const post = await client.fetch(POST_BY_SLUG_QUERY, { slug: "my-post" }); ``` **Why good:** `defineQuery()` enables TypeGen type inference, `$slug` parameter prevents injection, `[0]` returns single object (not array), `->` dereferences references, `pt::text()` extracts plain text from Portable Text, `^` references parent scope in subqueries ### Bad Example — String Interpolation ```typescript // BAD: GROQ injection vulnerability async function getPostBySlug(slug: string) { const query = `*[_type == "post" && slug.current == "${slug}"][0]`; return client.fetch(query); } ``` **Why bad:** User-controlled `slug` value interpolated directly into GROQ string, enables GROQ injection attacks (e.g., `" || true] | order(_createdAt desc)[0]{"token": identity()}//`), always use `$param` parameters instead --- ## Pattern 4: GROQ Queries — Advanced Patterns ### Good Example — Combined Query (Multiple Results in One Fetch) ```typescript const PAGE_QUERY = defineQuery(`{ "settings": *[_type == "siteSettings"][0]{ title, description, "logoUrl": logo.asset->url }, "featuredPosts": *[_type == "post" && featured == true] | order(publishedAt desc) [0...3]{ _id, title, "slug": slug.current, publishedAt }, "categories": *[_type == "category"] | order(title asc){ _id, title, "postCount": count(*[_type == "post" && references(^._id)]) } }`); // Single fetch returns all three datasets const pageData = await client.fetch(PAGE_QUERY); // pageData.settings, pageData.featuredPosts, pageData.categories ``` **Why good:** Single network request for multiple related datasets, each sub-query independently filtered and projected, `count()` with subquery for computed fields, reduces page load time ### Good Example — Conditional Projections ```typescript const CONTENT_BLOCKS_QUERY = defineQuery(` *[_type == "page" && slug.current == $slug][0]{ title, "content": content[]{ _type == "hero" => { _type, heading, "backgroundUrl": background.asset->url }, _type == "textBlock" => { _type, body }, _type == "gallery" => { _type, "images": images[]{ "url": asset->url, alt, caption } } } } `); ``` **Why good:** Conditional projections return different fields based on `_type`, polymorphic content blocks handled in a single query, each block type gets only its relevant fields --- ## Pattern 5: Error Handling ### Good Example — Consistent Fetch Error Handling ```typescript async function fetchPosts() { try { const posts = await client.fetch(POSTS_QUERY); return posts; } catch (error) { // Sanity client throws on network errors and GROQ syntax errors const message = error instanceof Error ? error.message : "Unknown error"; throw new Error(`Failed to fetch posts: ${message}`); } } // For single documents that might not exist async function fetchPostBySlug(slug: string) { const post = await client.fetch(POST_BY_SLUG_QUERY, { slug }); if (!post) { // [0] returns null when no documents match throw new Error(`Post not found: ${slug}`); } return post; } ``` **Why good:** `client.fetch` throws on network/GROQ errors (not a `{ data, error }` tuple), null check for `[0]` queries that may return no results, descriptive error messages with context ### Bad Example — No Null Handling ```typescript // BAD: Assumes document always exists async function getPost(slug: string) { const post = await client.fetch(POST_BY_SLUG_QUERY, { slug }); return post.title; // TypeError if post is null! } ``` **Why bad:** GROQ `[0]` returns `null` if no documents match, accessing `.title` on `null` throws at runtime, must check for `null` before accessing properties --- ## Pattern 6: TypeGen Integration ### Good Example — Full TypeGen Workflow ```typescript // sanity.cli.ts import { defineCliConfig } from "sanity/cli"; // NOTE: default export required by Sanity CLI tooling export default defineCliConfig({ api: { projectId: "your-project-id", dataset: "production", }, typegen: { enabled: true, path: "./src/**/*.{ts,tsx}", schema: "schema.json", generates: "./sanity.types.ts", overloadClientMethods: true, }, }); ``` ```typescript // queries/post-queries.ts import { defineQuery } from "groq"; // TypeGen finds these and generates result types export const allPostsQuery = defineQuery(` *[_type == "post"]{ _id, title, "slug": slug.current, publishedAt } | order(publishedAt desc) `); export const postBySlugQuery = defineQuery(` *[_type == "post" && slug.current == $slug][0]{ _id, title, body, "author": author->{name, "imageUrl": image.asset->url} } `); ``` ```typescript // lib/fetchers.ts import type { AllPostsQueryResult, PostBySlugQueryResult, } from "../sanity.types"; import { allPostsQuery, postBySlugQuery } from "../queries/post-queries"; import { client } from "./sanity-client"; // With overloadClientMethods: true, return types are inferred export async function getAllPosts(): Promise<AllPostsQueryResult> { return client.fetch(allPostsQuery); } export async function getPostBySlug( slug: string, ): Promise<PostBySlugQueryResult> { return client.fetch(postBySlugQuery, { slug }); } ``` **Why good:** `overloadClientMethods: true` makes `client.fetch` aware of query types, generated types match the exact shape of GROQ projections, queries in dedicated files for organization, explicit return types for clarity ### Bad Example — Inline Queries Without defineQuery ```typescript // BAD: TypeGen cannot type inline queries const posts = await client.fetch(`*[_type == "post"]{ title }`); // posts is typed as 'any' — no type safety ``` **Why bad:** TypeGen only generates types for queries assigned to named variables using `defineQuery()` or the `groq` template literal, inline strings produce `any` return type, no type safety on the result --- _For schema definitions, see [schemas.md](schemas.md). For Portable Text and images, see [rich-content.md](rich-content.md). For mutations and real-time, see [mutations.md](mutations.md)._ -
mutations.md 8.1 KB
# Sanity Mutations & Real-Time Examples > Create, patch, delete, transactions, and real-time listeners. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup. **Prerequisites**: Understand client setup from core examples first. Mutations require a client configured with `useCdn: false` and a write-capable API token. --- ## Pattern 1: Creating Documents ### Good Example — Create with Type Safety ```typescript import { client } from "../lib/sanity-client"; // create() auto-generates _id if not provided const newPost = await client.create({ _type: "post", title: "New Blog Post", slug: { _type: "slug", current: "new-blog-post" }, published: false, publishedAt: new Date().toISOString(), }); // newPost._id is the auto-generated document ID // createIfNotExists() — only creates if _id doesn't exist await client.createIfNotExists({ _id: "singleton-site-settings", _type: "siteSettings", title: "My Site", }); // createOrReplace() — overwrites entirely if document exists await client.createOrReplace({ _id: "singleton-site-settings", _type: "siteSettings", title: "Updated Site Title", description: "New description", }); ``` **Why good:** `create()` for new documents with auto-generated IDs, `createIfNotExists()` for idempotent initialization, `createOrReplace()` for singletons that should always reflect the latest state, slug includes `_type: "slug"` as required by the schema ### Bad Example — Create with Existing ID ```typescript // BAD: create() with an _id that already exists will error await client.create({ _id: "existing-doc-id", _type: "post", title: "This will fail", }); ``` **Why bad:** `create()` fails if a document with the given `_id` already exists — use `createOrReplace()` or `createIfNotExists()` for idempotent operations --- ## Pattern 2: Patching Documents ### Good Example — Various Patch Operations ```typescript // Set fields await client .patch("post-123") .set({ title: "Updated Title", published: true, publishedAt: new Date().toISOString(), }) .commit(); // Unset (remove) fields await client.patch("post-123").unset(["temporaryFlag", "draftNotes"]).commit(); // Increment/decrement numeric fields await client.patch("post-123").inc({ viewCount: 1 }).commit(); await client.patch("post-123").dec({ remainingSlots: 1 }).commit(); // Insert into an array at a specific position await client .patch("post-123") .insert("after", "tags[-1]", [ { _key: crypto.randomUUID(), label: "typescript" }, ]) .commit(); // Append to end of array await client .patch("post-123") .append("tags", [{ _key: crypto.randomUUID(), label: "new-tag" }]) .commit(); // Chain multiple patch operations await client .patch("post-123") .set({ title: "New Title" }) .inc({ revisionCount: 1 }) .commit(); ``` **Why good:** `.commit()` always called to send the mutation, `_key` included on array items (required by Sanity), `crypto.randomUUID()` generates unique keys, multiple operations chained in a single patch ### Bad Example — Missing commit() ```typescript // BAD: Patch without .commit() does nothing! client.patch("post-123").set({ title: "This is never sent" }); // No network request made — mutation is discarded ``` **Why bad:** Without `.commit()`, the patch is constructed but never sent to the API — this is a silent no-op --- ## Pattern 3: Deleting Documents ### Good Example — Delete by ID ```typescript // Delete a single document await client.delete("post-123"); // Delete with specific options await client.delete("post-123", { visibility: "async", // Return immediately, sync in background }); ``` **Why good:** Simple single-document deletion by ID, `visibility` option controls when the request returns --- ## Pattern 4: Transactions (Atomic Multi-Mutation) ### Good Example — Grouped Mutations ```typescript // All mutations in a transaction succeed or fail together await client .transaction() .create({ _type: "activityLog", action: "archived", documentId: "post-123", timestamp: new Date().toISOString(), }) .patch("post-123", (p) => p.set({ archived: true, archivedAt: new Date().toISOString() }), ) .commit(); // Transaction with multiple deletes const idsToDelete = ["post-1", "post-2", "post-3"]; const tx = client.transaction(); for (const id of idsToDelete) { tx.delete(id); } await tx.commit(); ``` **Why good:** Transaction ensures atomicity (both log creation and archival happen or neither does), callback style for patch within transaction, batch deletes in a single atomic operation --- ## Pattern 5: Mutation Visibility Options ### Good Example — Controlling Write Consistency ```typescript // sync (default): Waits for mutation to be committed AND indexed // Queries immediately see the change await client.create(doc, { visibility: "sync" }); // async: Returns after commit, indexes in background // Faster response, but queries may not reflect the change immediately await client.create(doc, { visibility: "async" }); // Dry run: Validates without applying const result = await client.create(doc, { dryRun: true }); // Useful for validating mutations before executing them ``` **Why good:** `sync` for consistency-critical operations (user sees their own change), `async` for bulk operations where speed matters, `dryRun` for validation without side effects --- ## Pattern 6: Real-Time Listeners ### Good Example — Subscribe to Document Changes ```typescript // Listen for changes to published posts const LISTENER_QUERY = `*[_type == "post" && published == true]`; const subscription = client.listen(LISTENER_QUERY).subscribe({ next: (update) => { if (update.type === "mutation") { const { documentId, transition, result } = update; // transition: 'update' | 'appear' | 'disappear' switch (transition) { case "appear": // Document now matches the filter (new or newly matching) console.log(`New post appeared: ${documentId}`); break; case "update": // Existing matching document was modified console.log(`Post updated: ${documentId}`); break; case "disappear": // Document no longer matches the filter (deleted or no longer matching) console.log(`Post disappeared: ${documentId}`); break; } } }, error: (err) => { console.error("Listener error:", err.message); }, }); // Cleanup: always unsubscribe when done subscription.unsubscribe(); ``` **Why good:** Observable-based subscription with error handling, `transition` field indicates what happened, cleanup via `unsubscribe()` prevents connection leaks, GROQ filter scopes to relevant documents ### Good Example — Listener with Options ```typescript // Listen with include options const subscription = client .listen( `*[_type == "post"]`, {}, // No parameters for this query { includeResult: true, // Include the full document in the event includePreviousRevision: false, // Don't include the previous version visibility: "query", // Only fire when the change is queryable }, ) .subscribe({ next: (update) => { if (update.type === "mutation" && update.result) { // update.result contains the full document console.log("Updated document:", update.result.title); } }, error: (err) => console.error(err), }); ``` **Why good:** `includeResult: true` gives the full document without a separate fetch, `visibility: "query"` ensures the event fires only when the change is visible to queries (consistent state) ### Important Caveats - **Projections are ignored** — `client.listen()` only uses the filter portion of a GROQ query. Projections, ordering, and slicing have no effect. - **Reconnection** — The listener automatically reconnects on disconnection, but events during the gap may be missed. For critical use cases, combine with periodic re-fetching. - **For production frontends** — Evaluate the newer Sanity Live Content API as a simpler alternative to raw listeners. --- _For client setup and GROQ, see [core.md](core.md). For schema definitions, see [schemas.md](schemas.md). For Portable Text and images, see [rich-content.md](rich-content.md)._ -
rich-content.md 7 KB
# Sanity Rich Content Examples > Portable Text rendering with @portabletext/react and image URL builder with @sanity/image-url. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup. **Prerequisites**: Understand client setup and GROQ queries from core examples first. --- ## Pattern 1: Basic Portable Text Rendering ### Good Example — PortableText with Custom Components ```tsx import { PortableText } from "@portabletext/react"; import type { PortableTextComponents, PortableTextBlock, } from "@portabletext/react"; import { urlFor } from "../lib/sanity-image"; const components: PortableTextComponents = { types: { image: ({ value }) => { if (!value?.asset?._ref) return null; return ( <figure> <img src={urlFor(value).width(800).auto("format").url()} alt={value.alt || ""} loading="lazy" /> {value.caption && <figcaption>{value.caption}</figcaption>} </figure> ); }, code: ({ value }) => ( <pre data-language={value.language}> <code>{value.code}</code> </pre> ), callout: ({ value }) => ( <aside data-tone={value.tone}> <PortableText value={value.text} /> </aside> ), }, marks: { link: ({ children, value }) => { const isExternal = value.href && !value.href.startsWith("/"); return ( <a href={value.href} rel={isExternal ? "noopener noreferrer" : undefined} target={isExternal ? "_blank" : undefined} > {children} </a> ); }, highlight: ({ children }) => <mark>{children}</mark>, inlineCode: ({ children }) => <code>{children}</code>, }, block: { h2: ({ children }) => <h2>{children}</h2>, h3: ({ children }) => <h3>{children}</h3>, blockquote: ({ children }) => <blockquote>{children}</blockquote>, normal: ({ children }) => <p>{children}</p>, }, list: { bullet: ({ children }) => <ul>{children}</ul>, number: ({ children }) => <ol>{children}</ol>, }, }; function ArticleBody({ body }: { body: PortableTextBlock[] }) { return <PortableText value={body} components={components} />; } export { ArticleBody }; ``` **Why good:** Custom renderers for image, code, and callout block types; external links get security attributes; images use `urlFor` for optimized delivery; null check for missing image assets; lazy loading for performance; named export ### Bad Example — Using Deprecated Package ```tsx // BAD: @sanity/block-content-to-react is deprecated import BlockContent from "@sanity/block-content-to-react"; // Uses old "serializers" API, unmaintained <BlockContent blocks={body} serializers={serializers} />; ``` **Why bad:** `@sanity/block-content-to-react` is deprecated and unmaintained, replaced by `@portabletext/react` which uses "components" instead of "serializers" --- ## Pattern 2: Image URL Builder Setup ### Good Example — Reusable urlFor Helper ```typescript // lib/sanity-image.ts import { createImageUrlBuilder } from "@sanity/image-url"; import type { SanityImageSource } from "@sanity/image-url"; import { client } from "./sanity-client"; const builder = createImageUrlBuilder(client); export function urlFor(source: SanityImageSource) { return builder.image(source); } ``` **Why good:** `createImageUrlBuilder` reads projectId and dataset from the client config, `urlFor` returns a chainable builder, `SanityImageSource` type handles all image input formats (asset ref, full image object, URL) ### Bad Example — Manual URL Construction ```typescript // BAD: Manually constructing image URLs function getImageUrl(ref: string) { const [, id, dimensions, format] = ref.split("-"); return `https://cdn.sanity.io/images/projectId/dataset/${id}-${dimensions}.${format}`; } ``` **Why bad:** Hardcoded project/dataset, ignores crop and hotspot metadata, no format optimization, no responsive sizing, breaks if Sanity changes CDN URL format --- ## Pattern 3: Responsive Images ### Good Example — Responsive srcSet with Auto Format ```tsx import { urlFor } from "../lib/sanity-image"; import type { SanityImageSource } from "@sanity/image-url"; const WIDTHS = [400, 800, 1200, 1600] as const; const DEFAULT_WIDTH = 800; function ResponsiveImage({ image, alt, sizes = "(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px", }: { image: SanityImageSource; alt: string; sizes?: string; }) { return ( <img src={urlFor(image).width(DEFAULT_WIDTH).auto("format").url()} srcSet={WIDTHS.map( (w) => `${urlFor(image).width(w).auto("format").url()} ${w}w`, ).join(", ")} sizes={sizes} alt={alt} loading="lazy" /> ); } export { ResponsiveImage }; ``` **Why good:** Named constants for widths, `.auto("format")` serves WebP/AVIF when the browser supports it, `srcSet` with width descriptors for responsive delivery, `sizes` attribute guides browser selection, lazy loading, crop and hotspot metadata automatically applied when passing the full image field --- ## Pattern 4: Image Transformations ### Good Example — Common Transformation Patterns ```typescript import { urlFor } from "../lib/sanity-image"; // Fixed-size thumbnail const thumbnailUrl = urlFor(image).width(150).height(150).fit("crop").url(); // Aspect ratio crop const heroUrl = urlFor(image) .width(1200) .height(400) .fit("crop") .auto("format") .url(); // Blur placeholder (for progressive loading) const blurUrl = urlFor(image).width(20).blur(50).url(); // Specific format const pngUrl = urlFor(image).width(800).format("png").url(); // Quality control const compressedUrl = urlFor(image).width(800).quality(75).auto("format").url(); ``` **Why good:** `.fit("crop")` respects hotspot when cropping, low-res blur for placeholder loading patterns, `.auto("format")` for automatic format selection, `.quality()` for size/quality tradeoff --- ## Pattern 5: Querying Portable Text as Plain Text ### Good Example — Using pt::text() in GROQ ```typescript import { defineQuery } from "groq"; // Extract plain text from Portable Text for excerpts and search const POSTS_WITH_EXCERPT_QUERY = defineQuery(` *[_type == "post" && published == true]{ _id, title, "slug": slug.current, "excerpt": pt::text(body)[0...200], publishedAt } | order(publishedAt desc) `); // Full-text search across Portable Text content const SEARCH_POSTS_QUERY = defineQuery(` *[_type == "post" && pt::text(body) match $searchTerm]{ _id, title, "slug": slug.current, "excerpt": pt::text(body)[0...200] } `); const results = await client.fetch(SEARCH_POSTS_QUERY, { searchTerm: "typescript*", }); ``` **Why good:** `pt::text()` converts Portable Text to plain text within GROQ (no client-side processing), string slicing `[0...200]` creates excerpts server-side, `match` operator for full-text search with wildcard support, parameterized search term prevents injection --- _For schema definitions, see [schemas.md](schemas.md). For mutations and real-time, see [mutations.md](mutations.md)._ -
schemas.md 9.6 KB
# Sanity Schema Examples > Schema definitions with defineType, defineField, defineArrayMember. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup. **Prerequisites**: Understand client setup from core examples first. --- ## Pattern 1: Document Type with Validation ### Good Example — Complete Document Schema ```typescript // schemas/post.ts import { defineType, defineField, defineArrayMember } from "sanity"; export const postType = defineType({ name: "post", title: "Blog Post", type: "document", fields: [ defineField({ name: "title", title: "Title", type: "string", validation: (rule) => rule.required().min(5).max(120), }), defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title", maxLength: 96, }, validation: (rule) => rule.required(), }), defineField({ name: "author", title: "Author", type: "reference", to: [{ type: "author" }], validation: (rule) => rule.required(), }), defineField({ name: "mainImage", title: "Main Image", type: "image", options: { hotspot: true }, fields: [ defineField({ name: "alt", title: "Alt Text", type: "string", validation: (rule) => rule.required(), }), ], }), defineField({ name: "categories", title: "Categories", type: "array", of: [ defineArrayMember({ type: "reference", to: [{ type: "category" }] }), ], }), defineField({ name: "publishedAt", title: "Published At", type: "datetime", }), defineField({ name: "body", title: "Body", type: "array", of: [ defineArrayMember({ type: "block" }), defineArrayMember({ type: "image", options: { hotspot: true }, fields: [ defineField({ name: "alt", title: "Alt Text", type: "string", }), defineField({ name: "caption", title: "Caption", type: "string", }), ], }), defineArrayMember({ type: "object", name: "code", title: "Code Block", fields: [ defineField({ name: "language", title: "Language", type: "string", }), defineField({ name: "code", title: "Code", type: "text" }), ], }), ], }), ], preview: { select: { title: "title", author: "author.name", media: "mainImage", }, prepare(selection) { const { author } = selection; return { ...selection, subtitle: author ? `by ${author}` : "No author", }; }, }, orderings: [ { title: "Published Date, New", name: "publishedAtDesc", by: [{ field: "publishedAt", direction: "desc" }], }, ], }); ``` **Why good:** `defineType`/`defineField`/`defineArrayMember` provide IDE autocomplete, validation rules enforce content quality, `hotspot: true` enables focal point cropping, alt text required on images for accessibility, body supports blocks (rich text), images, and custom code blocks, preview customizes Studio list appearance, orderings define sort options in Studio --- ## Pattern 2: Object Types (Reusable, Non-Document) ### Good Example — Reusable Object Schema ```typescript // schemas/objects/seo.ts import { defineType, defineField } from "sanity"; const SEO_TITLE_MAX_LENGTH = 60; const SEO_DESCRIPTION_MAX_LENGTH = 160; export const seoType = defineType({ name: "seo", title: "SEO", type: "object", fields: [ defineField({ name: "metaTitle", title: "Meta Title", type: "string", validation: (rule) => rule.max(SEO_TITLE_MAX_LENGTH), description: `Max ${SEO_TITLE_MAX_LENGTH} characters for search engines`, }), defineField({ name: "metaDescription", title: "Meta Description", type: "text", rows: 3, validation: (rule) => rule.max(SEO_DESCRIPTION_MAX_LENGTH), description: `Max ${SEO_DESCRIPTION_MAX_LENGTH} characters`, }), defineField({ name: "ogImage", title: "Open Graph Image", type: "image", description: "Recommended: 1200x630px", }), ], }); ``` **Why good:** Object type (not document) — can be embedded in any document via `type: "seo"`, named constants for magic numbers, `description` guides editors, `rows: 3` on text field improves Studio UX ### Usage in a Document ```typescript // In a page document defineField({ name: "seo", title: "SEO Settings", type: "seo", // References the object type defined above }), ``` --- ## Pattern 3: Reference Fields ### Good Example — Reference with Filtering ```typescript defineField({ name: "author", title: "Author", type: "reference", to: [{ type: "author" }], validation: (rule) => rule.required(), }), // Reference to multiple types (polymorphic) defineField({ name: "relatedContent", title: "Related Content", type: "array", of: [ defineArrayMember({ type: "reference", to: [{ type: "post" }, { type: "page" }, { type: "product" }], }), ], }), // Reference with filter (only show published authors) defineField({ name: "reviewer", title: "Reviewer", type: "reference", to: [{ type: "author" }], options: { filter: "active == true", filterParams: {}, }, }), ``` **Why good:** Single reference with validation, polymorphic reference allows multiple target types, `options.filter` limits reference picker in Studio to matching documents --- ## Pattern 4: Image and File Fields ### Good Example — Image with Metadata ```typescript defineField({ name: "mainImage", title: "Main Image", type: "image", options: { hotspot: true, // Enables focal point selection in Studio }, fields: [ defineField({ name: "alt", title: "Alternative Text", type: "string", validation: (rule) => rule.custom((alt, context) => { // Require alt text when an image is set const parent = context.parent as { asset?: { _ref?: string } }; if (parent?.asset?._ref && !alt) { return "Alt text is required when an image is set"; } return true; }), }), defineField({ name: "caption", title: "Caption", type: "string", }), ], }), // File field for downloads defineField({ name: "attachment", title: "Attachment", type: "file", options: { accept: ".pdf,.doc,.docx", }, fields: [ defineField({ name: "description", title: "Description", type: "string", }), ], }), ``` **Why good:** `hotspot: true` lets editors choose a focal point that `@sanity/image-url` respects during cropping, custom validation requires alt text only when an image is actually uploaded, file field restricts upload types with `accept` --- ## Pattern 5: Array Fields with Constraints ### Good Example — Array with Min/Max and Unique Items ```typescript defineField({ name: "tags", title: "Tags", type: "array", of: [defineArrayMember({ type: "string" })], validation: (rule) => rule.unique().min(1).max(10), options: { layout: "tags", // Renders as tag chips in Studio }, }), // Array of objects with constrained block types defineField({ name: "content", title: "Content", type: "array", of: [ defineArrayMember({ type: "block" }), defineArrayMember({ type: "image", options: { hotspot: true }, }), defineArrayMember({ type: "object", name: "callout", title: "Callout", fields: [ defineField({ name: "tone", title: "Tone", type: "string", options: { list: [ { title: "Info", value: "info" }, { title: "Warning", value: "warning" }, { title: "Tip", value: "tip" }, ], }, }), defineField({ name: "text", title: "Text", type: "array", of: [defineArrayMember({ type: "block" })], }), ], preview: { select: { tone: "tone" }, prepare({ tone }) { return { title: `Callout: ${tone || "default"}` }; }, }, }), ], }), ``` **Why good:** `.unique()` prevents duplicate tags, `layout: "tags"` improves Studio editing UX, custom inline objects (callout) have their own preview, content array mixes blocks, images, and custom types for flexible editorial experiences --- ## Pattern 6: Schema Registration ### Good Example — Registering Schemas in sanity.config.ts ```typescript // sanity.config.ts import { defineConfig } from "sanity"; import { structureTool } from "sanity/structure"; import { postType } from "./schemas/post"; import { authorType } from "./schemas/author"; import { categoryType } from "./schemas/category"; import { seoType } from "./schemas/objects/seo"; // NOTE: default export required by Sanity Studio export default defineConfig({ name: "default", title: "My Project", projectId: process.env.SANITY_PROJECT_ID!, dataset: process.env.SANITY_DATASET!, plugins: [structureTool()], schema: { types: [postType, authorType, categoryType, seoType], }, }); ``` **Why good:** Each schema in its own file, imported and registered in config, `structureTool()` provides the default document editor, schema types array includes both document types and reusable object types --- _For client and GROQ patterns, see [core.md](core.md). For Portable Text and images, see [rich-content.md](rich-content.md)._
-
-
reference.md 10.9 KB
# Sanity Reference > CLI commands, GROQ cheat sheet, type helpers, and quick-lookup tables. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. --- ## Sanity CLI Commands ### Project Setup ```bash # Install Sanity CLI globally (or use npx) npm install -g sanity # Initialize a new Sanity project npx sanity init # Start Sanity Studio (local dev) npx sanity dev # Build Studio for deployment npx sanity build # Deploy Studio to Sanity hosting npx sanity deploy ``` ### Type Generation ```bash # Extract schema to JSON npx sanity schema extract # Creates: schema.json # Generate TypeScript types from schema + GROQ queries npx sanity typegen generate # Watch mode (re-generates on changes) npx sanity typegen generate --watch ``` ### Dataset Management ```bash # List datasets npx sanity dataset list # Create a dataset npx sanity dataset create <name> # Export dataset npx sanity dataset export <dataset> <output-file> # Import dataset npx sanity dataset import <input-file> <dataset> ``` ### CORS Management ```bash # List CORS origins npx sanity cors list # Add a CORS origin npx sanity cors add http://localhost:3000 # Delete a CORS origin npx sanity cors delete http://localhost:3000 ``` --- ## Environment Variables ```bash # .env.local SANITY_PROJECT_ID=your-project-id SANITY_DATASET=production SANITY_API_TOKEN=sk... # Server-only — never expose to client ``` --- ## GROQ Quick Reference ### Filters | Pattern | GROQ | | -------------- | ---------------------------------------------------- | | All of type | `*[_type == "post"]` | | Multiple types | `*[_type in ["post", "page"]]` | | By slug | `*[_type == "post" && slug.current == $slug][0]` | | By reference | `*[_type == "post" && references($authorId)]` | | Boolean | `*[_type == "post" && published == true]` | | Comparison | `*[_type == "post" && publishedAt > "2024-01-01"]` | | Pattern match | `*[_type == "post" && title match "sanity*"]` | | Array contains | `*[_type == "post" && "typescript" in tags]` | | Has field | `*[_type == "post" && defined(featuredImage)]` | | Combined (AND) | `*[_type == "post" && published && count(tags) > 0]` | | Combined (OR) | `*[published == true \|\| _id in path("drafts.**")]` | ### Projections | Pattern | GROQ | | ------------------- | ------------------------------------------ | | Specific fields | `{ _id, title, slug }` | | Rename field | `{ "postTitle": title }` | | All fields | `{ ... }` | | All + computed | `{ ..., "excerpt": pt::text(body) }` | | Dereference | `{ "author": author->{ name, image } }` | | Array dereference | `{ "tags": tags[]->{ title } }` | | Nested array fields | `{ content[]{ _type, _key } }` | | Conditional fields | `{ ..., _type == "post" => { body } }` | | Coalesce | `{ "name": coalesce(displayName, email) }` | | Count | `{ "tagCount": count(tags) }` | ### Ordering and Slicing | Pattern | GROQ | | --------------- | ----------------------------------------- | | Ascending | `\| order(publishedAt asc)` | | Descending | `\| order(publishedAt desc)` | | Multi-field | `\| order(priority desc, _createdAt asc)` | | First item | `[0]` | | First N items | `[0...10]` (non-inclusive end) | | Inclusive range | `[0..9]` (inclusive end) | | Offset | `[10...20]` | | Parameterized | `[$start...$end]` | ### Joins and References | Pattern | GROQ | | ------------------- | ----------------------------------------------------------- | | Follow reference | `author->{ name }` | | Nested reference | `image{ asset->{ url } }` | | Array of references | `categories[]->{ title }` | | Reverse reference | `"posts": *[_type == "post" && references(^._id)]{ title }` | | Filter by reference | `*[references("author-id")]` | ### Special Variables | Variable | Meaning | | -------- | ------------------------------------------------------ | | `*` | All documents in the dataset | | `@` | Current document in scope | | `^` | Parent document (used in subqueries) | | `$param` | Query parameter (passed via `client.fetch` second arg) | ### Built-in Functions | Function | Description | | ------------------------------------- | ---------------------------------------- | | `count(array)` | Number of elements in array | | `defined(field)` | True if field has a value | | `coalesce(a, b, ...)` | First non-null value | | `round(num)` / `round(num, decimals)` | Round a number | | `lower(str)` / `upper(str)` | Case conversion | | `pt::text(portableText)` | Convert Portable Text to plain text | | `array::unique(arr)` | Remove duplicates | | `array::compact(arr)` | Remove null values | | `array::join(arr, separator)` | Join array to string | | `references(id)` | True if document references the given ID | | `select(cond => val, ...)` | Conditional value selection | | `score(expr)` | Scoring for search relevance | --- ## Schema Field Types | Type | Description | | ----------- | --------------------------------------------- | | `string` | Plain text string | | `text` | Multi-line text (no formatting) | | `number` | Numeric value | | `boolean` | True/false | | `date` | Date without time | | `datetime` | Date with time | | `slug` | URL-safe string (accessed via `.current`) | | `url` | Validated URL string | | `email` | Validated email string | | `image` | Image with optional hotspot/crop | | `file` | Uploaded file | | `array` | Array of items (requires `of` property) | | `object` | Inline object (not a document) | | `block` | Portable Text block (rich text) | | `reference` | Reference to another document (requires `to`) | | `geopoint` | Latitude/longitude point | --- ## Image URL Builder Methods | Method | Description | | ------------------- | --------------------------------------------------------------------- | | `.width(px)` | Set width in pixels | | `.height(px)` | Set height in pixels | | `.size(w, h)` | Set both width and height | | `.fit(mode)` | Resize mode: `clip`, `crop`, `fill`, `fillmax`, `max`, `scale`, `min` | | `.auto(type)` | Auto-format: `'format'` serves WebP/AVIF when supported | | `.format(fmt)` | Force format: `jpg`, `pjpg`, `png`, `webp` | | `.quality(q)` | JPEG/WebP quality (0-100) | | `.blur(amount)` | Apply Gaussian blur | | `.sharpen(amount)` | Apply sharpening | | `.flipHorizontal()` | Flip horizontally | | `.flipVertical()` | Flip vertically | | `.rect(x, y, w, h)` | Crop to specific rectangle | | `.focalPoint(x, y)` | Set focal point (0.0-1.0) | | `.url()` | Return the final URL string | --- ## Portable Text Component Types | Component Key | Purpose | | ------------- | ------------------------------------------------------ | | `types` | Custom block types (image, code, callout, video embed) | | `marks` | Inline annotations (link, highlight, footnote) | | `block` | Block-level styles (h1-h6, blockquote, normal) | | `list` | List types (bullet, number) | | `listItem` | List item rendering | | `hardBreak` | Line break rendering | --- ## Client Methods Quick Reference | Method | Description | | --------------------------------------- | --------------------------------------------------- | | `client.fetch(query, params?)` | Execute a GROQ query | | `client.create(doc)` | Create a new document (auto-generates `_id`) | | `client.createOrReplace(doc)` | Create or fully replace (requires `_id`) | | `client.createIfNotExists(doc)` | Create only if `_id` doesn't exist | | `client.patch(id).set({}).commit()` | Update specific fields | | `client.patch(id).unset([]).commit()` | Remove fields | | `client.patch(id).inc({}).commit()` | Increment numeric fields | | `client.patch(id).dec({}).commit()` | Decrement numeric fields | | `client.patch(id).insert(...).commit()` | Insert into arrays | | `client.delete(id)` | Delete a document | | `client.transaction()...commit()` | Group mutations atomically | | `client.listen(query)` | Subscribe to real-time changes (returns Observable) | | `client.getDocument(id)` | Fetch a single document by ID | -
SKILL.md 16.6 KB
--- name: api-cms-sanity description: Structured content platform — GROQ queries, schema definitions, @sanity/client, Portable Text, image handling, real-time listeners, mutations, TypeGen --- # Sanity Patterns > **Quick Guide:** Use Sanity for structured content management with GROQ queries, typed schemas via `defineType`/`defineField`, and `@sanity/client` for data fetching. Always set `apiVersion` to a dated string, use `useCdn: true` for public reads, handle draft documents explicitly, use `@sanity/image-url` for image transformations, and render rich text with `@portabletext/react`. Generate TypeScript types with `sanity typegen generate`. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST always set `apiVersion` on `createClient` to a dated string like `'2025-02-19'` — omitting it uses a legacy API that may break)** **(You MUST use `useCdn: true` for public read queries and `useCdn: false` when using a token or needing fresh data)** **(You MUST use parameterized GROQ queries (`$param`) for any dynamic values — never interpolate user input into GROQ strings)** **(You MUST handle drafts explicitly — draft documents have `_id` prefixed with `drafts.` and are not returned by default with `perspective: 'published'`)** **(You MUST use `defineQuery()` from `groq` and assign queries to named variables for TypeGen type generation)** </critical_requirements> --- **Auto-detection:** Sanity, sanity, @sanity/client, createClient, GROQ, groq, defineType, defineField, defineArrayMember, @sanity/image-url, urlFor, @portabletext/react, PortableText, portable text, block content, sanity.config, sanity.cli, typegen, sanity studio, content lake **When to use:** - Setting up `@sanity/client` with `createClient` for data fetching - Writing GROQ queries (filters, projections, joins, ordering, slicing) - Defining content schemas with `defineType`, `defineField`, `defineArrayMember` - Rendering Portable Text (block content) with `@portabletext/react` - Generating image URLs with `@sanity/image-url` (responsive images, crops, hotspots) - Performing mutations (create, patch, delete, transactions) - Setting up real-time listeners with `client.listen()` - Generating TypeScript types with Sanity TypeGen **Key patterns covered:** - Client setup with `createClient` and `apiVersion` configuration - GROQ query language: filters, projections, ordering, slicing, joins, references - Schema definitions: document types, object types, arrays, references, images - Portable Text rendering with custom components - Image URL builder with responsive images and transformations - Mutations: create, createOrReplace, patch, delete, transactions - Real-time listeners via `client.listen()` - TypeGen for type-safe GROQ queries with `defineQuery()` **When NOT to use:** - GraphQL-only APIs (Sanity supports GROQ primarily; use GraphQL skill if needed) - Direct database access (Sanity is a hosted content lake, not a database) - Non-Sanity CMS platforms (use the dedicated skill for your CMS) **Detailed Resources:** - For decision frameworks and quick-reference tables, see [reference.md](reference.md) **Client & GROQ:** - [examples/core.md](examples/core.md) — Client setup, GROQ queries, error handling, TypeGen **Schemas:** - [examples/schemas.md](examples/schemas.md) — defineType, defineField, document types, object types, references, images **Rich Content:** - [examples/rich-content.md](examples/rich-content.md) — Portable Text rendering, image URL builder, responsive images **Mutations & Real-time:** - [examples/mutations.md](examples/mutations.md) — Create, patch, delete, transactions, real-time listeners --- <philosophy> ## Philosophy Sanity is a structured content platform built around a real-time content lake, GROQ (Graph-Relational Object Queries) as its query language, and Sanity Studio as a customizable editing environment. **Core principles:** 1. **Structured content** — Content is defined by schemas (`defineType`, `defineField`) that describe shape, validation, and editorial UI. Schemas are code, not configuration files. 2. **GROQ-first querying** — GROQ lets you filter, project, join, and reshape data in a single query. Unlike REST or GraphQL, GROQ queries return exactly the shape you define in the projection. 3. **Content as data** — Rich text is stored as Portable Text (a JSON-based specification), making it renderable in any frontend framework without vendor lock-in. 4. **API versioning** — Every client must specify an `apiVersion` date string. This pins your code to a specific API behavior, preventing breaking changes from affecting production. 5. **CDN caching** — Public read queries use `useCdn: true` for edge-cached responses. Mutations and authenticated reads use `useCdn: false` for fresh data. 6. **Type generation** — Sanity TypeGen generates TypeScript types from both your schemas and GROQ queries, enabling end-to-end type safety from content model to frontend. **When to use Sanity:** - Content-driven websites and applications (blogs, marketing sites, documentation) - Projects needing real-time collaborative editing in a customizable studio - Multi-channel content delivery (web, mobile, IoT) from a single content source - Teams wanting type-safe content queries with GROQ and TypeGen **When NOT to use:** - Transactional data requiring ACID guarantees (use a database) - User-generated content at massive scale (Sanity is optimized for editorial content) - Projects needing a self-hosted CMS (Sanity's content lake is hosted, though the Studio is open source) </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Client Setup with createClient Configure `@sanity/client` with project ID, dataset, API version, and CDN preference. Always set `apiVersion` to a dated string and `useCdn` explicitly. ```typescript import { createClient } from "@sanity/client"; export const client = createClient({ projectId: process.env.SANITY_PROJECT_ID!, dataset: process.env.SANITY_DATASET!, apiVersion: "2025-02-19", // Pin to a specific API version date useCdn: true, // true for public reads, false for authenticated/fresh data }); ``` For dual client setup (public + preview with token), see [examples/core.md](examples/core.md). --- ### Pattern 2: GROQ Queries with Filters and Projections GROQ queries combine filters, projections, ordering, and slicing. Always use `defineQuery()` for TypeGen and `$param` for dynamic values. ```typescript import { defineQuery } from "groq"; const POST_BY_SLUG_QUERY = defineQuery(` *[_type == "post" && slug.current == $slug][0]{ _id, title, body, "author": author->{name, image} } `); const post = await client.fetch(POST_BY_SLUG_QUERY, { slug: "my-post" }); ``` Never interpolate user input into GROQ strings -- always use `$param` parameters to prevent GROQ injection. For advanced queries (combined queries, conditional projections), see [examples/core.md](examples/core.md). --- ### Pattern 3: Schema Definitions with defineType and defineField Define content structure with `defineType`, `defineField`, and `defineArrayMember` from `"sanity"` for type safety and Studio UI. ```typescript import { defineType, defineField } from "sanity"; export const postType = defineType({ name: "post", type: "document", fields: [ defineField({ name: "title", type: "string", validation: (r) => r.required(), }), defineField({ name: "slug", type: "slug", options: { source: "title" } }), ], }); ``` For complete schemas (images with hotspot, arrays, references, previews, object types), see [examples/schemas.md](examples/schemas.md). --- ### Pattern 4: Portable Text Rendering Render block content with `@portabletext/react`. Define custom `PortableTextComponents` for non-standard blocks (images, code) and marks (links, highlights). ```tsx import { PortableText } from "@portabletext/react"; <PortableText value={body} components={components} />; ``` Do not use the deprecated `@sanity/block-content-to-react` package. For full component examples, see [examples/rich-content.md](examples/rich-content.md). --- ### Pattern 5: Image URL Builder Use `@sanity/image-url` to generate optimized, responsive image URLs with crop and hotspot support. ```typescript import { createImageUrlBuilder } from "@sanity/image-url"; const builder = createImageUrlBuilder(client); export function urlFor(source: SanityImageSource) { return builder.image(source); } // Usage: urlFor(image).width(800).auto("format").url() ``` For responsive `srcSet` patterns and image transformations, see [examples/rich-content.md](examples/rich-content.md). --- ### Pattern 6: Mutations (Create, Patch, Delete) Use `@sanity/client` methods for document mutations. Always call `.commit()` on patches and transactions. ```typescript await client.create({ _type: "post", title: "New Post" }); await client.patch("post-123").set({ title: "Updated" }).commit(); await client.delete("post-123"); ``` For `createOrReplace`, `createIfNotExists`, transactions, array inserts, and visibility options, see [examples/mutations.md](examples/mutations.md). --- ### Pattern 7: Real-Time Listeners Subscribe to document changes with `client.listen()`. The listener only uses the filter portion of GROQ -- projections and ordering are ignored. ```typescript const subscription = client.listen(`*[_type == "post"]`).subscribe({ next: (update) => { /* update.transition: 'update' | 'appear' | 'disappear' */ }, error: (err) => console.error(err), }); subscription.unsubscribe(); // Cleanup when done ``` For production frontends, evaluate the newer Live Content API as a simpler alternative. For listener options and caveats, see [examples/mutations.md](examples/mutations.md). --- ### Pattern 8: TypeGen for Type-Safe GROQ Configure TypeGen in `sanity.cli.ts` with `overloadClientMethods: true` for typed `client.fetch` results. Use `defineQuery()` from `"groq"` to make queries discoverable by TypeGen. ```typescript // sanity.cli.ts — set typegen.overloadClientMethods: true // queries/post-queries.ts — wrap queries with defineQuery() // sanity.types.ts — auto-generated result types const posts = await client.fetch(allPostsQuery); // Typed result ``` Inline query strings without `defineQuery()` produce untyped (`any`) results. For full TypeGen configuration and workflow, see [examples/core.md](examples/core.md). </patterns> --- <decision_framework> ## Decision Framework ### useCdn: true vs false ``` Is the data public and non-personalized? ├─ YES → useCdn: true (edge-cached, fast) └─ NO → ├─ Using a token for authenticated reads? → useCdn: false ├─ Need real-time fresh data (preview)? → useCdn: false └─ Performing mutations? → useCdn: false ``` ### Draft Handling ``` Do you need draft documents? ├─ YES → Use perspective: 'previewDrafts' (requires token) ├─ NO → Use perspective: 'published' (default since API v2025-02-19) └─ Mixed (preview mode toggle)? └─ Create two clients: one public (useCdn: true), one preview (token + useCdn: false) ``` ### GROQ Query Return Shape ``` How many documents do you expect? ├─ One (by ID, slug, singleton) → Add [0] at end (returns object or null) ├─ Many (list, feed) → No slice suffix (returns array) └─ Paginated → Add [start...end] slice ``` ### Image Handling ``` How should images be delivered? ├─ Fixed size (thumbnails, avatars) → urlFor(img).width(W).height(H).url() ├─ Responsive (article images) → srcSet with multiple widths ├─ Format optimization → .auto('format') for WebP/AVIF └─ Cropped to aspect ratio → .width(W).height(H).fit('crop') ``` ### Mutation Method Selection ``` What operation do you need? ├─ Create new document → client.create() ├─ Create or fully replace → client.createOrReplace() (for singletons) ├─ Create only if missing → client.createIfNotExists() ├─ Update specific fields → client.patch(id).set({...}).commit() ├─ Remove fields → client.patch(id).unset([...]).commit() ├─ Multiple related changes → client.transaction()...commit() └─ Delete document → client.delete(id) ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - **Missing `apiVersion` on client** — Omitting `apiVersion` uses legacy API behavior that may change without notice. Always pin to a date string. - **String interpolation in GROQ queries** — Interpolating user input into GROQ strings enables GROQ injection. Always use `$param` parameters. - **Using `useCdn: true` with a token** — CDN-cached responses ignore authentication tokens. Authenticated queries must use `useCdn: false`. - **Accessing draft documents without a token** — Drafts (`drafts.*` documents) require an API token and `perspective: 'previewDrafts'`. Without a token, drafts are invisible. **Medium Priority Issues:** - **Fetching all fields with `{...}` when only a few are needed** — Over-fetching wastes bandwidth and CDN cache efficiency. Project only the fields you need. - **Missing `.commit()` on patches** — `client.patch(id).set({...})` without `.commit()` does nothing — the mutation is never sent. - **Not using `defineQuery()` for GROQ queries** — TypeGen cannot generate types for queries that aren't wrapped in `defineQuery()` or assigned to named variables. - **Hardcoded project ID or dataset** — Use environment variables; hardcoded values prevent environment switching and leak project details. - **Using deprecated `@sanity/block-content-to-react`** — Replaced by `@portabletext/react`. The old package is unmaintained. **Common Mistakes:** - **Forgetting `_key` on array items in mutations** — Every item in a Sanity array must have a unique `_key` field. Mutations without `_key` will fail. - **Using `_id` with `client.create()`** — `create()` generates a random `_id`. If you specify `_id` and the document exists, it errors. Use `createOrReplace()` or `createIfNotExists()` for idempotent operations. - **Not handling the case where `[0]` returns `null`** — A GROQ query ending in `[0]` returns `null` if no documents match, not an empty object. - **Expecting `client.listen()` to work with projections** — The listener only uses the filter portion of a GROQ query. Projections, ordering, and slicing are ignored. **Gotchas & Edge Cases:** - **API version `2025-02-19` changed default perspective** — Before this version, the default perspective was `raw` (includes drafts). After, it defaults to `published`. Existing code may break if you update `apiVersion` without accounting for this. - **CDN cache is eventual** — After a mutation, CDN-cached queries (`useCdn: true`) may return stale data for a few seconds. Use `useCdn: false` or add a small delay for consistency-critical reads after writes. - **Slug fields store value in `.current`** — Query `slug.current`, not `slug` directly. `*[slug == "my-slug"]` will never match. - **Image fields require the full object for crop/hotspot** — Passing only `asset._ref` to `urlFor()` works for basic URLs but loses crop and hotspot metadata. Pass the entire image field object. - **Portable Text arrays need `_key` on every block** — When creating Portable Text content programmatically, every block and inline object needs a unique `_key`. - **`client.listen()` reconnects automatically** — But there's no built-in guarantee against missed events during reconnection. For critical use cases, combine with periodic re-fetching. - **TypeGen requires `schema.json` extraction first** — Run `npx sanity schema extract` before `npx sanity typegen generate`. The extract step reads your Studio schemas and outputs a JSON representation. </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST always set `apiVersion` on `createClient` to a dated string like `'2025-02-19'` — omitting it uses a legacy API that may break)** **(You MUST use `useCdn: true` for public read queries and `useCdn: false` when using a token or needing fresh data)** **(You MUST use parameterized GROQ queries (`$param`) for any dynamic values — never interpolate user input into GROQ strings)** **(You MUST handle drafts explicitly — draft documents have `_id` prefixed with `drafts.` and are not returned by default with `perspective: 'published'`)** **(You MUST use `defineQuery()` from `groq` and assign queries to named variables for TypeGen type generation)** **Failure to follow these rules will cause unpredictable API behavior, GROQ injection vulnerabilities, and untyped query results.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.