Sanity Studio Production Tips: Lessons from 3000+ Posts
We've been running Sanity as our primary CMS across multiple client projects for over three years now. Somewhere around the 3,000-post mark, you stop thinking about Sanity in terms of what the docs say and start thinking about it in terms of what actually survives production. This article is that brain dump -- every schema decision we regretted, every GROQ query that brought a build to its knees, and every Studio customization that made editors actually want to use the CMS instead of emailing us Word docs.
This isn't a getting-started guide. If you're here, you've probably already set up Sanity Studio, created a few schemas, and maybe shipped a site or two. What I want to share are the patterns that only emerge after you've dealt with real content teams, real editorial workflows, and real performance budgets at scale.
Table of Contents
- Schema Design That Survives Real Content Teams
- GROQ Performance at Scale: What Actually Matters
- Studio Customizations Worth the Investment
- Content Migration and Data Integrity
- Deployment and Environment Strategy
- Monitoring and Debugging in Production
- Performance Benchmarks from Real Projects
- FAQ

Schema Design That Survives Real Content Teams
Schema design is where most Sanity projects silently fail. Not in a dramatic crash-and-burn way -- more like a slow erosion of editorial confidence. The content team starts avoiding certain fields. They create workarounds. Six months later, half your structured content is actually jammed into a single rich text block because the schema was "too complicated."
Stop Over-Nesting Objects
Our biggest early mistake was creating deeply nested object structures. We'd model content like a database schema -- normalized, elegant, technically correct. A blog post had an author reference, which had a bio object, which had a socialLinks array of objects, each with a platform reference.
Editors hated it. Every time they needed to update an author's Twitter handle, they were five clicks deep. Here's what we do now:
// Before: Over-engineered
export default defineType({
name: 'author',
type: 'document',
fields: [
defineField({
name: 'name',
type: 'string',
}),
defineField({
name: 'bio',
type: 'object',
fields: [
defineField({
name: 'content',
type: 'array',
of: [{ type: 'block' }],
}),
defineField({
name: 'socialLinks',
type: 'array',
of: [
defineArrayMember({
type: 'object',
fields: [
{ name: 'platform', type: 'reference', to: [{ type: 'platform' }] },
{ name: 'url', type: 'url' },
],
}),
],
}),
],
}),
],
})
// After: Flat, editor-friendly
export default defineType({
name: 'author',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'bio', type: 'array', of: [{ type: 'block' }] }),
defineField({ name: 'twitter', type: 'url', title: 'Twitter / X URL' }),
defineField({ name: 'linkedin', type: 'url', title: 'LinkedIn URL' }),
defineField({ name: 'github', type: 'url', title: 'GitHub URL' }),
],
})
Yes, the flat version is less "pure." It also gets used correctly 100% of the time. Trade-off accepted.
Use Field Groups Aggressively
Once a document type has more than 8-10 fields, editors start scrolling and missing things. Sanity v3's field groups are undersold. We put them on every document type with more than six fields:
export default defineType({
name: 'post',
type: 'document',
groups: [
{ name: 'content', title: 'Content', default: true },
{ name: 'seo', title: 'SEO' },
{ name: 'settings', title: 'Settings' },
],
fields: [
defineField({ name: 'title', type: 'string', group: 'content' }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }], group: 'content' }),
defineField({ name: 'seoTitle', type: 'string', group: 'seo' }),
defineField({ name: 'seoDescription', type: 'text', rows: 3, group: 'seo' }),
defineField({ name: 'publishDate', type: 'datetime', group: 'settings' }),
defineField({ name: 'featured', type: 'boolean', group: 'settings' }),
],
})
Validation That Guides, Not Gates
We learned to think of validation as UX, not enforcement. Hard required() validations on every field means editors can't save drafts. Custom validation messages that explain why something matters get way better compliance than generic error states:
defineField({
name: 'excerpt',
type: 'text',
rows: 3,
validation: (rule) =>
rule
.max(160)
.warning('Excerpts over 160 characters get truncated in search results and social cards.'),
})
Notice that's a warning, not an error. The editor can still publish. They just know the consequences.
GROQ Performance at Scale: What Actually Matters
GROQ is wonderful until it isn't. At 500 documents, everything is fast. At 3,000+ documents with references, images, and portable text, you start noticing things.
Projections Are Not Optional
The single biggest GROQ performance lever is projections. Stop fetching entire documents when you only need three fields. I've seen Next.js builds go from 4 minutes to 90 seconds just by fixing GROQ projections in generateStaticParams calls.
// Slow: fetches everything including portable text, images, references
*[_type == "post"]
// Fast: only what the listing page actually needs
*[_type == "post"] | order(publishedAt desc) [0...20] {
_id,
title,
slug,
publishedAt,
"authorName": author->name,
"thumbnailUrl": thumbnail.asset->url
}
That author->name inline dereference is critical. It avoids fetching the entire author document. When you have 3,000 posts each referencing one of 50 authors, the difference is measurable.
The Join Problem Nobody Talks About
Sanity's GROQ documentation shows dereferencing like it's free. It's not. Every -> in a query is essentially a join. Stack three or four of them in a list query that returns 100 results and you'll feel it.
We profile every GROQ query in our projects now. Here's our rule of thumb:
| Pattern | Documents | Avg Response Time |
|---|---|---|
| Simple fetch, no refs | 3,000 | ~120ms |
One level of -> dereference |
3,000 | ~250ms |
Two levels of -> |
3,000 | ~600ms |
Nested array with -> inside |
3,000 | ~1,200ms+ |
These are real numbers from our Sanity API dashboard in mid-2025. Your mileage will vary based on document size, but the trend is consistent.
GROQ Patterns We Use Constantly
Conditional fetching for preview vs. published:
*[_type == "post" && slug.current == $slug && ($preview || !(_id in path('drafts.**')))] [0] {
...,
"author": author-> { name, slug, image },
"categories": categories[]-> { title, slug }
}
Paginated queries with count:
{
"posts": *[_type == "post"] | order(publishedAt desc) [$start...$end] {
_id, title, slug, publishedAt,
"authorName": author->name
},
"total": count(*[_type == "post"])
}
Related posts without N+1:
*[_type == "post" && slug.current == $slug][0] {
...,
"related": *[_type == "post" && _id != ^._id && count(categories[@._ref in ^.^.categories[]._ref]) > 0] | order(publishedAt desc) [0...3] {
title, slug, publishedAt
}
}
That related posts query is dense, but it runs server-side in Sanity's infrastructure, so it's usually faster than making two round trips.
Studio Customizations Worth the Investment
Vanilla Sanity Studio is fine for developers. It's not fine for content teams shipping 20 posts a week. Here's what we customize on every project.
Custom Document Actions
The default publish action doesn't trigger webhooks for incremental builds reliably in every setup. We wrap it:
import { useDocumentOperation } from 'sanity'
export function createPublishWithWebhookAction(originalPublishAction) {
return function PublishWithWebhook(props) {
const originalResult = originalPublishAction(props)
return {
...originalResult,
onHandle: async () => {
await originalResult.onHandle()
// Trigger ISR revalidation or deploy hook
await fetch('/api/revalidate', {
method: 'POST',
body: JSON.stringify({ type: props.type, id: props.id }),
})
},
}
}
}
Structure Builder for Editorial Workflows
The default desk structure shows every document type in a flat list. At 15+ document types, this is chaos. We use Structure Builder to create editorial-focused navigation:
import { StructureBuilder } from 'sanity/structure'
export const structure = (S: StructureBuilder) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Blog')
.child(
S.list()
.title('Blog')
.items([
S.listItem()
.title('Published Posts')
.child(
S.documentList()
.title('Published')
.filter('_type == "post" && !(_id in path("drafts.**"))')
),
S.listItem()
.title('Drafts')
.child(
S.documentList()
.title('Drafts')
.filter('_type == "post" && _id in path("drafts.**")')
),
S.listItem()
.title('All Posts')
.child(S.documentTypeList('post').title('All Posts')),
])
),
S.divider(),
// ... other content types
])
This takes 30 minutes to set up and saves editors hours of confusion every week.
Portable Text Custom Components
One thing that bit us hard: editors pasting content from Google Docs into the Portable Text editor. The default block editor handles this okay, but custom block types need explicit serializers or they show up as empty boxes and editors panic.
We register custom components for every block type:
defineArrayMember({
type: 'object',
name: 'codeBlock',
title: 'Code Block',
fields: [
defineField({ name: 'code', type: 'text' }),
defineField({ name: 'language', type: 'string',
options: { list: ['javascript', 'typescript', 'python', 'bash', 'groq'] }
}),
],
preview: {
select: { code: 'code', language: 'language' },
prepare({ code, language }) {
return {
title: `Code (${language || 'plain'})`,
subtitle: code?.slice(0, 80) + '...',
}
},
},
})
That preview config is tiny but essential. Without it, editors see blank blocks and don't know what they are.

Content Migration and Data Integrity
We've done five major content migrations into Sanity -- from WordPress, Contentful, Prismic, markdown files, and a custom Rails CMS. Every single one taught us something painful.
Use the Migration Tooling, But Trust and Verify
Sanity's @sanity/migrate package and the CLI's sanity documents import work well for straightforward cases. For anything involving portable text conversion, write custom scripts. Always.
# Export everything for backup before any migration
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz
We run this before every migration, every schema deploy, and honestly, every Monday morning via cron. Datasets are cheap. Lost content isn't.
Schema Versioning Strategy
Sanity doesn't enforce schema versions at the data layer. This is both a feature and a foot-gun. Old documents don't magically update when you change a schema. We use a simple pattern:
defineField({
name: 'schemaVersion',
type: 'number',
hidden: true,
initialValue: 2,
readOnly: true,
})
Then in migration scripts, we can query *[_type == "post" && schemaVersion < 2] and batch-update documents to the new format. It's crude but it works.
Deployment and Environment Strategy
Sanity's dataset model supports multiple environments, and you should use them from day one -- not after your first production data incident.
Our Standard Setup
| Environment | Dataset | Studio URL | Purpose |
|---|---|---|---|
| Production | production |
studio.client.com | Live content editing |
| Staging | staging |
staging-studio.client.com | Content QA, schema testing |
| Development | development |
localhost:3333 | Schema development |
We clone production to staging weekly using sanity dataset copy production staging. This keeps staging realistic without risking production data during schema experiments.
For the frontend, our Next.js development projects use environment variables to switch datasets:
const config = {
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
apiVersion: '2025-01-01',
useCdn: process.env.NODE_ENV === 'production',
}
CDN vs. No CDN
Sanity's API CDN is eventually consistent. For published content on a marketing site, this is fine -- the CDN is fast and the staleness window is typically under 2 seconds. For preview/draft content, always bypass the CDN:
const client = sanityClient.withConfig({
useCdn: false,
token: process.env.SANITY_PREVIEW_TOKEN,
perspective: 'previewDrafts',
})
We've seen preview issues that took hours to debug, only to realize the preview client was hitting the CDN and showing stale data. Set useCdn: false for all preview and draft-reading contexts.
Monitoring and Debugging in Production
GROQ Query Profiling
Sanity's management console (manage.sanity.io) shows API usage metrics, but the granularity isn't always enough. We log slow queries on the frontend side:
async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
const start = performance.now()
const result = await client.fetch<T>(query, params)
const duration = performance.now() - start
if (duration > 500) {
console.warn(`Slow GROQ query (${duration.toFixed(0)}ms):`, query.slice(0, 200))
}
return result
}
Anything over 500ms in production gets investigated. Usually it's an unprojected query or a nested dereference that snuck through code review.
Webhook Reliability
Sanity webhooks are reliable but not infallible. We've seen occasional missed webhooks during Sanity infrastructure updates. For critical workflows (like triggering rebuilds on our Astro development projects), we implement a polling fallback:
// Check for recent changes every 5 minutes as a safety net
const POLL_INTERVAL = 5 * 60 * 1000
setInterval(async () => {
const lastModified = await client.fetch(
`*[_type == "post"] | order(_updatedAt desc) [0]._updatedAt`
)
if (new Date(lastModified) > lastKnownUpdate) {
await triggerRebuild()
lastKnownUpdate = new Date(lastModified)
}
}, POLL_INTERVAL)
Performance Benchmarks from Real Projects
Here are real numbers from three production projects we shipped in 2024-2025 using Sanity with headless frontends:
| Metric | Project A (Next.js) | Project B (Astro) | Project C (Next.js) |
|---|---|---|---|
| Total documents | 3,200 | 1,800 | 4,100 |
| Document types | 12 | 8 | 18 |
| Avg GROQ response (CDN) | 85ms | 72ms | 130ms |
| Avg GROQ response (no CDN) | 180ms | 145ms | 290ms |
| Full static build time | 3m 20s | 1m 45s | 6m 10s |
| ISR revalidation | 1.2s | N/A (static) | 1.8s |
| Monthly API requests | ~450K | ~180K | ~1.2M |
| Sanity plan cost/mo | Growth ($99) | Free | Growth ($99) |
Project C's longer build time was entirely due to image processing, not GROQ. Once we moved to Sanity's image pipeline with @sanity/image-url and proper width/height parameters, the build stopped downloading full-resolution images.
For headless CMS development projects, Sanity's pricing is competitive. The free tier is genuinely usable for smaller sites. The Growth plan at $99/month covers most mid-size editorial operations. You only start hitting cost concerns at very high API request volumes, and even then, aggressive CDN usage and smart caching keep things reasonable.
When Sanity Isn't the Right Choice
I'd be doing you a disservice if I didn't mention the cases where we've steered clients away from Sanity:
- Highly relational data (product catalogs with complex variant relationships) -- a purpose-built commerce platform or even Postgres makes more sense
- Extremely non-technical teams who need a WYSIWYG page builder -- Sanity's Portable Text is powerful but it's not Squarespace
- Budget-constrained projects with >200K monthly API requests -- costs can surprise you
For everything else -- especially editorial content, marketing sites, and documentation -- Sanity has been our go-to CMS. If you're evaluating options for a headless project, reach out to us and we'll give you an honest assessment based on your specific needs.
FAQ
How many documents can Sanity handle before performance degrades? We've run production projects with over 4,000 documents without meaningful degradation. Sanity's hosted infrastructure handles document counts well into the tens of thousands. The performance bottleneck is almost always in how you write GROQ queries -- specifically, unprojected fetches and deep reference chains -- not the raw document count.
Should I use GROQ or GraphQL with Sanity? GROQ, unless you have a very specific reason to use GraphQL. GROQ is more expressive for Sanity's document model, supports projections more naturally, and gets first-class attention from the Sanity team. The GraphQL API is auto-generated from your schema and works fine, but you lose some of the query flexibility that makes Sanity powerful.
How do you handle draft preview with Sanity and Next.js?
We use Next.js Draft Mode combined with Sanity's perspective: 'previewDrafts' setting. The preview client bypasses the CDN and uses a read token. Sanity's @sanity/preview-kit package provides real-time listeners that update the page as editors type. It takes some setup but the editorial experience is worth it.
What's the best way to structure Portable Text for SEO?
Map your Portable Text block styles to proper semantic HTML. Use h2, h3, h4 styles (not just "large text" or "heading"). Add custom block types for structured data like FAQ sections, how-to steps, and code blocks. We render Portable Text to HTML using @portabletext/react with custom serializers that output schema.org-friendly markup.
How do you handle image optimization with Sanity?
Sanity's image pipeline is excellent. Use @sanity/image-url to generate URLs with specific dimensions and format parameters. Always set auto=format to let Sanity serve WebP or AVIF based on browser support. For Next.js projects, we use the Sanity image loader with next/image -- this gives you both Sanity's CDN and Next.js's built-in image optimization.
Can Sanity handle localized/multilingual content at scale?
Yes, but your schema design matters enormously. We use the document-level internationalization pattern (separate documents per locale linked by a shared i18nId field) rather than field-level translation objects. At 3,000+ documents across three locales, this keeps queries simple and avoids the massive document sizes you get when every field contains an object with 5+ language keys.
How often should you update your Sanity API version?
Pin your API version to a specific date (like 2025-01-01) and update it quarterly after reviewing the changelog. Sanity's API versioning is date-based and breaking changes are rare, but they do happen. We've been bitten by undocumented GROQ behavior changes between API versions -- always test your critical queries after bumping the version.
What's the cost of Sanity for a large editorial team? The Growth plan at $99/month (as of mid-2025) includes 1M API requests, 500K API CDN requests, and 20 users. For most editorial teams publishing 20-50 posts per week, this is more than sufficient. The primary cost driver is API requests -- every GROQ query from your frontend counts. Use CDN aggressively, cache where possible, and avoid client-side fetches that multiply with traffic.