Sanity Best Practices
SanityOfficialPublished by Sanity
- Skill
- Tool integration
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a comprehensive collection of best practices, architectural patterns, and integration guides for the Sanity ecosystem. It covers the full development lifecycle, including schema design, GROQ query optimization, TypeGen configuration, and framework-specific integrations like Next.js, Angular, and Remix. It also includes guidance on infrastructure management via Blueprints, content automation with Sanity Functions, and building custom applications with the Sanity App SDK.
What it does
- Provides standardized patterns for Sanity schema design and content modeling.
- Offers integration guides for major frontend frameworks and the Sanity App SDK.
- Defines best practices for GROQ query performance and type safety with TypeGen.
- Outlines rules for media handling, including video asset management.
- Details configuration for Sanity Studio structure and Visual Editing.
- Explains infrastructure-as-code workflows using Sanity Blueprints.
When to use it
- When starting a new Sanity project or onboarding team members.
- When integrating Sanity with frameworks like Next.js, Astro, or Angular.
- When optimizing GROQ queries or implementing type-safe data fetching.
- When designing complex content schemas or implementing localization.
- When setting up live previews, Visual Editing, or custom Studio structures.
- When automating content workflows or managing infrastructure via Blueprints.
Advantages
- Maintained by Sanity, ensuring alignment with current platform standards.
- Reduces technical debt by enforcing consistent schema and query patterns.
- Provides clear decision matrices for complex topics like video and localization.
- Includes framework-specific guidance to accelerate frontend integration.
- Promotes maintainable project structures and type-safe development workflows.
Usage examples
- “What are the best practices for handling video in Sanity?”
- The skill advises against using standard file assets for production video due to bandwidth costs, recommending the Sanity Media Library with Mux for Enterprise plans or dedicated services like Mux, YouTube, or Vimeo for others.
- “How do I set up TypeGen for an Angular monorepo?”
- The skill provides a configuration example for studio/sanity.cli.ts to point TypeGen at the Angular source files, ensuring generated types are correctly integrated into the Angular project.
- “What is the recommended way to structure singleton documents?”
- The skill recommends using explicit document IDs for singleton documents and controlling them via the Studio Structure, while letting Sanity generate IDs for all other document types.
- “How should I fetch data in a React app using the App SDK?”
- The skill mandates wrapping data-fetching components in Suspense, using documentId as a React key, and preferring useDocuments and useDocumentProjection over raw GROQ hooks.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/sanity-io/sanity-best-practices/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip sanity-best-practices.zip -d .agents/skills
Documentation by Sanity
Sanity Best Practices
Comprehensive best practices and integration guides for Sanity development, maintained by Sanity. Use the quick reference below to load only the one or two topic files that match the task.
When to Apply
Reference these guidelines when:
Setting up a new Sanity project or onboarding
Integrating Sanity with a frontend framework (Next.js, Nuxt, Astro, Remix, SvelteKit, Hydrogen)
Writing GROQ queries or optimizing performance
Designing content schemas
Implementing Visual Editing and live preview
Working with images, Portable Text, or page builders
Configuring Sanity Studio structure
Setting up TypeGen for type safety
Implementing localization
Migrating content from other systems
Building custom apps with the Sanity App SDK
Developing or deploying a Studio or app in the Sanity Dashboard
Managing infrastructure with Blueprints
Automating content workflows with Sanity Functions or webhooks
Global Rules
Let Sanity generate
_idvalues for ordinary documents. Do not create deterministic UUIDs, slug-derived IDs, or legacy-system IDs when creating documents.Model relationships with
referencefields, then resolve related documents with GROQ lookups, source-key fields, or returned_idvalues from created documents.Use explicit document IDs mainly for singleton documents controlled by Studio Structure, including localized singletons such as
homePage-en.
Video
Do not store or serve video from Sanity
fileassets for production playback. File assets are delivered as raw downloads with no transcoding or adaptive streaming, and video traffic drives very high bandwidth usage and unexpectedly large bills.On Enterprise plans with the video add-on, use Sanity Media Library for video: uploads are transcoded and streamed adaptively via Mux. Model video fields with
defineVideoField()fromsanity/media-libraryand play them with@mux/mux-player-reactusing the asset's playback ID.On other plans, use a dedicated video service: install
sanity-plugin-mux-inputto upload and manage videos in your Mux account from the Studio, or host video on a platform such as YouTube or Vimeo and store only the embed URL in Sanity.Small clips and short previews in a
filefield are acceptable, but any user-facing video at scale must go through Media Library or a streaming service.
Quick Reference
Integration Guides
get-started- Interactive onboarding for new Sanity projectsnextjs- Next.js App Router, Live Content API, standalone Studionuxt- Nuxt integration with @nuxtjs/sanityangular- Angular integration with @sanity/client, signals, resource APIastro- Astro integration with @sanity/astroremix- React Router / Remix integrationsvelte- SvelteKit integration with @sanity/svelte-loaderhydrogen- Shopify Hydrogen with Sanityproject-structure- Standalone Studio and monorepo patternsapp-sdk- Custom applications with Sanity App SDKdashboard- Studios and apps in the Sanity Dashboard: defineApplication, local dev, deploys, Dashboard hooksblueprints- Infrastructure as Code: blueprint files, stacks, plan/deploy workflow, error recovery, CI deploysfunctions- Automating content workflows with Sanity Functions and webhooks
Topic Guides
groq- GROQ query patterns, type safety, performance optimizationschema- Schema design, field definitions, validation, deprecation patternsvisual-editing- Presentation Tool, Stega, overlays, live previewpage-builder- Page Builder arrays, block components, live editingportable-text- Rich text rendering and custom componentsimage- Image schema, URL builder, hotspots, LQIP, Next.js Imagestudio-structure- Desk structure, singletons, navigationtypegen- TypeGen configuration, workflow, type utilitiesseo- Metadata, sitemaps, Open Graph, JSON-LDlocalization- i18n patterns, document vs field-level, locale managementmigration- Content import overview (see alsomigration-html-import)migration-html-import- HTML to Portable Text with @portabletext/block-tools
How to Use
Start with the single framework or topic guide that best matches the request, then read additional references only when the task crosses concerns. Use these reference files for detailed explanations and code examples:
references/groq.md
references/schema.md
references/nextjs.mdEach reference file contains:
Comprehensive topic or integration coverage
Incorrect and correct code examples
Decision matrices and workflow guidance
Framework-specific patterns where applicable
Files
- ATTRIBUTION.mdAdded by AutoGineer873 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md5.4 KiB
- references/angular.md18.6 KiB
- references/app-sdk.md12.0 KiB
- references/astro.md4.9 KiB
- references/blueprints.md14.1 KiB
- references/dashboard.md6.5 KiB
- references/functions.md29.7 KiB
- references/get-started.md21.0 KiB
- references/groq.md14.4 KiB
- references/hydrogen.md6.8 KiB
- references/image.md3.3 KiB
- references/localization.md12.0 KiB
- references/migration-html-import.md5.2 KiB
- references/migration.md2.7 KiB
- references/nextjs.md16.3 KiB
- references/nuxt.md5.9 KiB
- references/page-builder.md8.2 KiB
- references/portable-text.md9.8 KiB
- references/project-structure.md3.5 KiB
- references/remix.md8.8 KiB
- references/schema.md12.4 KiB
- references/seo.md8.3 KiB
- references/studio-structure.md4.0 KiB
- references/svelte.md10.0 KiB
- references/typegen.md6.1 KiB
- references/visual-editing.md8.4 KiB
references/groq.md
title: GROQ Query Maintenance & Best Practices description: Guidelines for GROQ queries, type safety, performance optimization, and syntax highlighting.
GROQ Query Maintenance & Best Practices
Constructing queries before execution
Start with the smallest query that returns the requested result. Use known document IDs, types, and fields, and use query parameters for filter values:
*[_id == $id][0]{_id, title, "launchCode": metadata.launchCode}Before executing queries with reference lookups in filters, mixed OR conditions, computed sorts, or deep pagination, review Performance Rules. A small returned result does not establish that the query is cheap to execute.
Projection keys and attribute traversal
Plain fields can use {_id, title}. Give nested fields and computed expressions an explicit quoted output key:
*[_type == "article"][0...10]{
_id,
"displayName": title,
"slug": slug.current,
"launchCode": metadata["launchCode"]
}{displayName: title} is invalid: quote the alias. {slug.current} and {metadata["launchCode"]} also need explicit keys. After a dot, use an attribute name, such as .title; do not write .(title).
Use functions that GROQ actually provides
Do not assume JavaScript or SQL functions exist in GROQ. Check the GROQ functions reference for unfamiliar operations.
To join an array into a string, use
array::join(tags, ", "). There is no built-instring::join().There is no built-in
keys(),object::keys(), orarray::keys(). To report the properties present in an object, retrieve that object and inspect the returned JSON outside GROQ. Do not retry by guessing another namespace. To learn declared fields instead, read the schema.For a conditional value, use
select(featured => title, "Other"). A standalonefeatured => titleis not a value expression. Conditional projection branches use objects, such asfeatured => {title}.To count matching documents, use
count(*[_type == "article"]).
To inspect the fields present on a document, fetch it by ID:
*[_id == $id][0]Read the property names from the returned object outside GROQ. This does not require a key-enumeration function in the query.
Check the complete expression
Before submission, check that filters and slices close with ], projections with }, and function calls with ). For example:
*[_type == "article" && featured == true][0...10]{title}count(*[_type == "article" && featured == true])After a syntax rejection, use the reported position and error to inspect the relevant expression, correct it, and retry. Do not resend the unchanged query or change a function namespace without checking that the replacement exists.
Use this contents list to jump to the query concern you need to solve.
Table of Contents
Constructing queries before execution
Query definition and imports
Query fragments
Expansion patterns
Maintenance workflow
Common patterns
Performance rules
API version best practices
1. Query Definition & Imports
The defineQuery Function
In application code, wrap GROQ queries in defineQuery for TypeGen support. The import location depends on your framework:
// Framework-agnostic (Angular, Remix, SvelteKit, Astro, vanilla)
import { defineQuery } from "groq";
// Next.js (re-exported for convenience)
import { defineQuery } from "next-sanity";Syntax Highlighting
For VS Code syntax highlighting, either:
Use the
groqtagged template (recommended):groq\...``Or prefix with
/* groq */comment when usingdefineQuery
import { defineQuery } from "groq";
// ✅ Option A: groq tag (provides highlighting automatically)
import groq from "groq";
const QUERY = defineQuery(groq`*[_type == "post"]`);
// ✅ Option B: Comment prefix (for plain template literals)
const QUERY = defineQuery(/* groq */ `*[_type == "post"]`);
// ✅ Also valid: Just defineQuery (TypeGen works, but no editor highlighting)
const QUERY = defineQuery(`*[_type == "post"]`);2. Query Fragments
Use string interpolation to reuse query logic and keep queries maintainable.
// src/sanity/fragments/image.ts
export const imageFragment = /* groq */ `
asset->{
_id,
url,
metadata { lqip, dimensions }
},
alt
`;
// src/sanity/queries/post.ts
import { defineQuery } from "groq";
import { imageFragment } from "../fragments/image";
export const POST_QUERY = defineQuery(/* groq */ `
*[_type == "post"][0] {
title,
mainImage {
${imageFragment}
}
}
`);3. Expansion Patterns (Page Builder)
When building a Page Builder query, expand all potential component types.
Best Practice: Use a pageFields fragment or similar strategy to keep the main query clean.
const pageBuilderExpansion = /* groq */ `
pageBuilder[] {
...,
_type == "hero" => {
...,
cta[] { link, label }
},
_type == "gallery" => {
images[] { ${imageFragment} }
}
}
`;4. Maintenance Workflow
When you add a new field or component to the Schema:
Update the Query: Add the new field/expansion to the relevant GROQ query immediately.
Run TypeGen: If you have
typegen.enabled: trueinsanity.cli.ts, types regenerate automatically duringsanity dev/sanity build. Otherwise, runnpm run typegenmanually.Verify: Ensure the new field is available in the generated types.
5. Common Patterns
Ordering
// Single field
*[_type == "post"] | order(publishedAt desc)
// Multiple fields (tiebreaker)
*[_type == "post"] | order(featured desc, publishedAt desc)
// ⚠️ Order BEFORE slice, not after!
*[_type == "post"] | order(publishedAt desc)[0...10] // ✅ Correct
*[_type == "post"][0...10] | order(publishedAt desc) // ❌ Wrong orderSlice Notation
*[_type == "post"][0] // Single document (object, not array)
*[_type == "post"][0...5] // First 5 (exclusive) ← Most commonSlice bounds must be constant numbers — $params aren't allowed. For dynamic pagination, validate the numbers in application code and interpolate them directly into the query string:
const start = Number.isInteger(page) && page >= 0 ? page * pageSize : 0
const end = start + pageSize
const query = `*[_type == "post"] | order(publishedAt desc)[${start}...${end}]`Default Values with coalesce()
*[_type == "page"]{
"title": coalesce(seoTitle, title, "Untitled"),
"image": coalesce(ogImage, mainImage, defaultImage)
}Conditionals with select()
*[_type == "product"]{
title,
"badge": select(
stock == 0 => "Out of Stock",
stock < 5 => "Low Stock",
"In Stock"
)
}Aggregation with count()
// Total count
count(*[_type == "post" && defined(slug.current)])
// Count per document
*[_type == "category"]{
title,
"postCount": count(*[_type == "post" && references(^._id)])
}Reverse References
*[_type == "author"]{
name,
"posts": *[_type == "post" && references(^._id)]{ title, slug }
}Array Filtering
*[_type == "movie"]{
title,
"mainCast": castMembers[role == "lead"]->{name}
}
// Match a known category ID (see Avoid Joins in Filters for dynamic lookups)
*[_type == "post" && $categoryId in categories[]._ref]Special Variables
// ^ = parent document (in nested queries)
*[_type == "author"]{
name,
"posts": *[_type == "post" && author._ref == ^._id]
}
// @ = current item (in array operations)
*[_type == "post"]{
"tagCount": count(tags[@ != null])
}6. Performance Rules
Optimizable vs Non-Optimizable Filters
GROQ uses indexes for optimizable filters. Other expressions require evaluating candidate documents. An indexed _type constraint can narrow those candidates; without an indexed restriction, the query may scan the dataset.
| Pattern | Optimizable | Example |
|---|---|---|
_type == "x" | ✅ Yes | *[_type == "post"] |
_id == "x" | ✅ Yes | *[_id == "abc123"] |
slug.current == $slug | ✅ Yes | *[slug.current == "hello"] |
defined(field) | ✅ Yes | *[defined(publishedAt)] |
references($id) | ✅ Yes | *[references("author-123")] |
field->attr == x | ❌ No | Resolves references while evaluating candidates |
fieldA < fieldB | ❌ No | Compares two attributes |
Reduce the candidates for non-optimizable filters:
// Add selective indexed constraints; textual order does not force execution order
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]Avoid Joins in Filters
Reference resolution (->) in filters is expensive. Use _ref instead:
// ❌ Slow: Resolves reference for every document
*[_type == "post" && author->name == "Bob Woodward"]
// ✅ Fast: Direct _ref comparison
*[_type == "post" && author._ref == "author-bob-woodward-id"]When you need dynamic lookups (don't know the IDs upfront), fetch all matching IDs and pass them as a parameter:
// Two-step approach:
// 1. Get every matching author ID
*[_type == "author" && name == $name]._id
// 2. Pass every returned ID as $authorIds in the main query
*[_type == "post" && author._ref in $authorIds]Using [0] in the lookup would silently drop other authors with the same name. Discover the actual reference target types from the schema. Keep the same dataset, API version, and perspective in both requests. If the ID set needs pagination, collect all pages rather than silently truncating it; two requests can observe intervening content changes. A nested lookup is another option, but verify its timings rather than assuming it is faster.
Mixed OR Filters Can Still Require Joins
// Resolves brands while testing product candidates
*[_type == "product" && (
name match $search || brand->name match $search
)]{_id, name, "brand": brand->name} | order(name asc)The indexed name match branch does not make the whole OR optimizable: a product can qualify solely through its brand. Resolve matching brand IDs first, using the schema's target type (here brand):
*[_type == "brand" && name match $search]._idPass that complete result as $brandIds, keeping $search unchanged (for example, "Acme*"):
*[_type == "product" && (
name match $search || brand._ref in $brandIds
)] | order(name asc) {
_id,
name,
"brand": brand->name
}An empty $brandIds array still permits direct product-name matches. Keep match semantics, both OR branches, ordering, and returned fields intact. Dereferencing in the projection is appropriate when the caller needs the related value; the expensive pattern here is dereferencing to decide which documents qualify.
Small Results Do Not Prove Cheap Execution
A final slice limits returned results, but may still leave substantial filtering or sorting work. count() can also be expensive when its filter requires joins or other per-document evaluation. Add pagination when the task permits it; do not add a limit that changes a request for all results.
Before claiming an improvement, compare results and timings on representative data with the same parameters, API version, and perspective. For a two-step rewrite, include both requests in the timing. Check multiple matching reference targets, no matching targets, and missing references. Local GROQ evaluation can check semantics, but cannot establish Content Lake performance.
Merge Repeated Reference Resolutions
Each -> is a subquery. Don't repeat it:
// ❌ Slow: Two separate subqueries
*[_type == "category"]{
"parentTitle": parent->title,
"parentSlug": parent->slug.current
}
// ✅ Fast: Single subquery, merged
*[_type == "category"]{
...(parent->{ "parentTitle": title, "parentSlug": slug.current })
}Cursor-Based Pagination (Not Deep Slicing)
Deep slices are slow because all skipped docs must be sorted first.
// ❌ Slow: Must sort and skip 10,000 docs
*[_type == "article"] | order(_id)[10000...10020]
// ✅ Fast: Cursor-based, only fetches 20
*[_type == "article" && _id > $lastId] | order(_id)[0...20]For custom sort orders, include the sort field in the cursor:
// Compound cursor: publishedAt + _id for deterministic pagination
*[_type == "article" && (
publishedAt < $lastDate ||
(publishedAt == $lastDate && _id > $lastId)
)] | order(publishedAt desc, _id)[0...20]Always Project Fields
Always use projections to return only the fields your application needs. Fetching entire documents wastes bandwidth and processing time.
// ❌ Returns ALL fields including unused ones, metadata, revisions
*[_type == "post"]
// ✅ Only fetch what the component needs
*[_type == "post"]{
_id,
title,
"slug": slug.current,
publishedAt,
excerpt
}Apply projections at every level, including nested references:
*[_type == "post"]{
title,
author->{ name, "avatar": image.asset->url },
categories[]->{ title, "slug": slug.current }
}Use conditional projections for different contexts:
*[_type == "post"]{
title,
slug,
// Only include body for single post view
$includeBody == true => { body }
}Don't Filter/Sort on Projected Values
Computed attributes can't use indexes:
// ❌ Not optimizable (computed attribute)
*[_type == "person"]{
"fullName": firstName + " " + lastName
} | order(fullName)
// ✅ Optimizable (original attribute)
*[_type == "person"] | order(firstName, lastName)Quick Checklist
| Rule | Why |
|---|---|
Always project { fields } | Reduces data returned |
Use defined() checks | Filters use indexes |
Use $params not interpolation | Prevents query manipulation + enables caching (exception: slice bounds — see Slice Notation) |
| Order BEFORE slice | order()[0...N] not [0...N] order() |
Use _ref not ->field in filters | Avoids expensive joins |
Merge repeated -> calls | Single subquery vs many |
| Cursor pagination for deep pages | Avoids sorting entire dataset |
7. API Version Best Practices
Always use dated versions (YYYY-MM-DD) for consistent behavior:
const client = createClient({
apiVersion: '2026-02-01', // Use current date for new projects
})New projects: Use current date (e.g.,
2026-02-01)Existing projects: Keep current version unless you need new features
Dated versions lock behavior;
v1orvXmay change unexpectedly
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Sanity
- Publisher
- Sanity (Official)
- Source
- sanity-io/agent-toolkit · skills/sanity-best-practices · commit 3ed3a14
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Sanity or Sanity.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download sanity-best-practices.zip