Key takeaways

  • Flatten deeply nested schemas and use field groups. Editors miss fields that sit more than a couple of clicks deep.
  • Project every GROQ query and limit chained -> dereferences. Each level of dereferencing adds cost. Unprojected queries are the most common source of slow builds.
  • Customize Structure Builder and document actions. This helps publishing reliably trigger rebuilds and lets editors tell drafts from published content at a glance.
  • Run production, staging, and development as separate datasets from day one. Always set useCdn: false for preview and draft content.
  • In the bdManagedIT migration from WordPress to Astro and Sanity, pairing zero-JS static pages with careful schema and query design helped push PageSpeed scores above 95.

Updated 26 August 2026: sources added, experience claims checked against our project record, summary added.

Sanity builds that feel instant at a few hundred documents can stall badly past a couple of thousand. Progress bars freeze. A GROQ query that ran fast locally times out on Vercel. Editors start emailing Word docs with tracked changes instead of opening Studio. Past roughly 1,500 documents, the schema patterns the docs recommend start to strain. Reference chains that looked elegant turn into build-time bottlenecks. Studio customizations that felt clever early on generate editor complaints later.

This is not a getting-started guide. It assumes you have already set up Sanity Studio, created a few schemas, and shipped at least one site. The patterns below only show up once real content teams, real editorial workflows, and real performance budgets get involved at scale.

Sanity Studio at Scale: GROQ, Schemas and Production Survival

Schema Design That Survives Real Content Teams

Schema design is where most Sanity projects silently fail. Not in a dramatic crash. More like a slow erosion of editorial confidence. The content team starts avoiding certain fields, then creates workarounds. Six months later, half the structured content sits jammed into one rich text block because the schema was "too complicated."

Stop Over-Nesting Objects

Modeling content like a database schema, normalized, technically correct, and deeply nested, is a common early mistake. A blog post gets an author reference, which has a bio object, which has a socialLinks array of objects, each with its own platform reference.

Editors hate this. Updating an author's Twitter handle means five clicks deep into nested objects. A flatter structure works better:

// 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 far more.

That trade-off is worth making.

Use Field Groups Aggressively

Once a document type has more than eight or so fields, editors start scrolling and missing things. Sanity v3's field groups fix this cleanly. Add them to 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

Validation works best as UX guidance, not strict rules. Hard required() rules on every field stop editors from saving drafts. Custom validation messages that explain why something matters get much 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 great until it isn't. At 500 documents, everything is fast. At 3,000+ documents with references, images, and portable text, problems start to show.

Projections Are Not Optional

The single biggest GROQ performance lever is projections. Fetching whole documents when you only need three fields wastes bandwidth and build time. In production Next.js builds, fixing GROQ projections inside generateStaticParams calls can cut build times a lot:

// 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 matters. It avoids fetching the whole author document. With several thousand posts each referencing one of a few dozen authors, the difference is easy to feel.

The Join Problem Nobody Talks About

Sanity's GROQ documentation shows dereferencing like it's free. It isn't. Every -> in a query acts like a join. Stack three or four of them in a list query that returns 100 results and you will feel it.

Profiling GROQ queries is worth doing on every project. A useful rule of thumb:

Pattern Relative Response Time (illustrative, ~3,000 documents)
Simple fetch, no refs Baseline
One level of -> dereference Noticeably slower
Two levels of -> Slower still
Nested array with -> inside Significantly slower, often the main bottleneck

These are illustrative rankings, not fixed benchmarks. Actual numbers depend on document size and network conditions, but the ordering holds across production Sanity projects.

Common GROQ Patterns

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 inside Sanity's infrastructure. It is faster than making two round trips.

Studio Customizations Worth the Investment

Vanilla Sanity Studio works fine for developers. It does not work well for content teams shipping dozens of posts a week. A few customizations pay off on most projects.

Custom Document Actions

The default publish action does not reliably trigger webhooks for incremental builds in every setup. Here is a wrapper that fixes this:

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 turns into chaos. Structure Builder can build editorial-focused navigation instead:

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 is a small time investment that saves editors real confusion.

Portable Text Custom Components

Editors paste content from Google Docs into the Portable Text editor, a common failure mode. The default block editor handles this well enough, but custom block types need explicit serializers, or they show up as empty boxes and editors panic.

Registering custom components for every block type avoids this:

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.

Sanity Studio at Scale: GROQ, Schemas and Production Survival - architecture

Content Migration and Data Integrity

Migrating into Sanity from an existing CMS surfaces problems a fresh Studio never shows you. In the bdManagedIT migration from WordPress to Astro and Sanity, the same lesson applied on every pass: verify everything, and never trust an automated import blindly.

Use the Migration Tooling, But Trust and Verify

Sanity's @sanity/migrate package and the CLI's sanity documents import work well for simple 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

Run this export before every migration and every schema deploy, and on a regular schedule via cron. It's cheap insurance. Datasets are cheap. Lost content is not.

Schema Versioning Strategy

Sanity does not enforce schema versions at the data layer. This is both a feature and a foot-gun, since old documents do not update automatically when a schema changes. A simple pattern handles this:

defineField({
  name: 'schemaVersion',
  type: 'number',
  hidden: true,
  initialValue: 2,
  readOnly: true,
})

Migration scripts can then 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. Use them from day one, not after your first production data incident.

A Standard Environment 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

Cloning production to staging on a regular schedule with sanity dataset copy production staging keeps staging realistic without risking production data during schema experiments.

Next.js development projects use environment variables on the frontend to switch datasets:

const config = {
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  apiVersion: '2026-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 short for typical publishing schedules. For preview and draft content, always bypass the CDN:

const client = sanityClient.withConfig({
  useCdn: false,
  token: process.env.SANITY_PREVIEW_TOKEN,
  perspective: 'previewDrafts',
})

Preview issues that trace back to the CDN serving stale drafts are a common debugging trap. Set useCdn: false for all preview and draft-reading contexts.

Monitoring and Debugging in Production

GROQ Query Profiling

Sanity's management console shows API usage metrics, but the detail isn't always enough. Logging slow queries on the frontend side helps:

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
}

Queries over 500ms in production are worth checking. An unprojected query or a nested dereference that slipped through code review is the culprit.

Webhook Reliability

Sanity webhooks are reliable but not perfect. Occasional missed webhooks during infrastructure updates are a known edge case. For critical workflows, such as triggering rebuilds on Astro development projects, a polling fallback is worth adding:

// 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 in Production

Projected GROQ queries and CDN-backed reads stay fast well past a few thousand documents, though exact numbers vary by project. Image processing, not GROQ, drives build time.

The table below shows general patterns, not one specific client engagement:

Metric Smaller sites Larger, content-heavy sites
GROQ response, CDN, projected query Fast Still fast, but more sensitive to reference depth
GROQ response, no CDN Noticeably slower than CDN Noticeably slower than CDN
Static build time Minutes Longer, usually dominated by image processing rather than GROQ
Monthly API requests Fits comfortably in the free tier Often needs a paid plan

Image processing is the biggest driver of build time, not GROQ. Using Sanity's image pipeline with @sanity/image-url and explicit width/height parameters avoids downloading full-resolution images during the build. This keeps build times reasonable even as document and image counts grow.

For headless CMS development projects, Sanity's pricing is competitive. The free tier works for smaller sites, and the Growth plan at $99/month covers most mid-size editorial operations. At very high API request volumes, cost concerns show up, and even then, aggressive CDN usage and caching keep things manageable.

When Sanity Isn't the Right Choice

Sanity is not the right fit in a few cases:

  • 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 is not Squarespace
  • Budget-constrained projects with very high monthly API request volumes: costs can add up fast, so check the pricing page for current limits

Sanity remains a strong choice for editorial content, marketing sites, and documentation. If you are weighing options for a headless project, reach out to us and we will give you an honest assessment based on your specific needs.

FAQ

How many documents can Sanity handle before performance degrades?

Sanity's hosted infrastructure scales well beyond a few thousand documents without hitting a hard platform limit. In practice, performance problems almost always come from unprojected GROQ queries and deep reference chains, not from the raw document count itself.

Should I use GROQ or GraphQL with Sanity?

Use GROQ over GraphQL unless you have a specific reason not to. GROQ is more expressive for Sanity's document model and supports projections natively. It also gets first-class support from the Sanity team. The auto-generated GraphQL API works, but it loses some of that query flexibility.

How do you handle draft preview with Sanity and Next.js?

Draft preview in Sanity and Next.js combines Next.js Draft Mode with Sanity's perspective: 'previewDrafts' setting. The preview client bypasses the CDN and uses a read token. Sanity's @sanity/preview-kit package adds real-time listeners so the page updates as editors type, without needing a manual refresh.

What's the best way to structure Portable Text for SEO?

Structuring Portable Text for SEO means mapping block styles to proper semantic HTML: real h2, h3, and h4 styles rather than a generic "heading" label. Add custom block types for structured data such as FAQ sections, how-to steps, and code blocks. Then render everything through @portabletext/react with schema.org-friendly serializers.

How do you handle image optimization with Sanity?

Sanity's image pipeline handles most of the work. Use @sanity/image-url to generate URLs with specific dimensions and format parameters, and set auto=format so Sanity serves WebP or AVIF depending on browser support. For Next.js projects, pairing the Sanity image loader with next/image combines Sanity's CDN with Next.js's built-in optimization.

Can Sanity handle localized or multilingual content at scale?

Yes, but schema design matters a lot. The document-level internationalization pattern, using separate documents per locale linked by a shared i18nId field, scales better than field-level translation objects. This keeps queries simple and avoids bloated documents where every field carries multiple language keys.

How often should you update your Sanity API version?

Pin your API version to a specific date, such as 2026-01-01. Review the changelog before bumping it, ideally on a quarterly cadence. Sanity's versioning is date-based, and breaking changes are rare but do happen, so test critical GROQ queries after each version bump.

What's the cost of Sanity for a large editorial team?

Sanity's Growth plan costs $99 per month and includes 1M API requests, 500K API CDN requests, and 20 users. That covers most editorial teams publishing dozens of posts a week. The main cost driver is API requests, since every GROQ call from the frontend counts, so using the CDN and caching aggressively keeps costs predictable.