Je Sanity build stopt bij 2.400 documenten. De voortgangsbalk bevriest. Een GROQ query die sub-200ms draaide in je lokale omgeving timeout nu op Vercel. Een editor stuurt je een Word doc met tracked changes in plaats van Studio te openen. We hebben in de afgelopen drie jaar 3.000+ posts over verschillende klantprojecten deployed, en voorbij de drempel van 1.500 documenten veranderen de regels. De schemapatronen die de docs aanbevelen beginnen te breken. Referenceketens die je elegant dacht te hebben ontworpen, worden build-time mijnenvelden. Studio customizations die in maand één slim voelden, worden editorklachten in maand zes. Wat volgt is geen theorie – het zijn de schemabeslissingen die we terugdraaid hebben, de GROQ herschrijvingen die build time met 70% reduceerden, en de drie Studio tweaks die de Word-doc emails stopzetten.

Dit is geen getting-started gids. Als je hier bent, heb je waarschijnlijk al Sanity Studio ingericht, een paar schemas gemaakt, en misschien al een site of twee gelanceerd. Wat ik wil delen zijn de patronen die alleen naar voren komen nadat je met echte content teams, echte redactionele workflows, en echte performance budgets op schaal hebt gewerkt.

Inhoudsopgave

Sanity Studio Production Tips: Lessen van 3000+ Posts

Schemaontwerp dat echte content teams overleeft

Schemaontwerp is waar de meeste Sanity projecten stilzwijgend mislukken. Niet op een dramatische crash-and-burn manier -- meer als een langzame erosie van redactioneel vertrouwen. Het content team begint bepaalde velden te vermijden. Ze creëren workarounds. Zes maanden later wordt de helft van je gestructureerde content eigenlijk in een enkel rich text blok gepropt omdat het schema "te ingewikkeld" was.

Stop met over-nesting van objecten

Onze grootste vroege fout was het creëren van diep geneste objectstructuren. We modelleerden content als een databaseschema -- genormaliseerd, elegant, technisch correct. Een blogpost had een author reference, die een bio object had, die een socialLinks array van objecten had, elk met een platform reference.

Editors haattten het. Telkens wanneer ze een Twitter handle van een auteur moesten updaten, waren ze vijf klikken diep. Dit doen we nu:

// Voor: Over-engineerd
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' },
              ],
            }),
          ],
        }),
      ],
    }),
  ],
})

// Na: Plat, editor-vriendelijk
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' }),
  ],
})

Ja, de platte versie is minder "zuiver". Het wordt ook 100% van de tijd correct gebruikt. Trade-off aanvaard.

Gebruik Field Groups agressief

Zodra een documenttype meer dan 8-10 velden heeft, beginnen editors te scrollen en dingen te missen. Sanity v3's field groups zijn ondergewaardeerd. We zetten ze op elk documenttype met meer dan zes velden:

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' }),
  ],
})

Validatie die begeleidt, niet blokkeert

We leerden om validatie als UX te beschouwen, niet als handhaving. Hard required() validaties op elk veld betekent dat editors geen concepten kunnen opslaan. Custom validatieberichten die uitleggen waarom iets belangrijk is, krijgen veel beter naleving dan generieke foutmeldingen:

defineField({
  name: 'excerpt',
  type: 'text',
  rows: 3,
  validation: (rule) =>
    rule
      .max(160)
      .warning('Samengevatte teksten over 160 tekens worden afgekapt in zoekresultaten en social cards.'),
})

Merk op dat dit een warning is, geen error. De editor kan nog steeds publiceren. Ze weten alleen de gevolgen.

GROQ Performance op Schaal: Wat werkelijk belangrijk is

GROQ is geweldig tot het dat niet is. Bij 500 documenten is alles snel. Bij 3.000+ documenten met references, afbeeldingen en portable text, begin je dingen op te merken.

Projections zijn niet optioneel

De enkele grootste GROQ performance hefboom is projections. Stop met het ophalen van hele documenten als je maar drie velden nodig hebt. Ik heb Next.js builds zien gaan van 4 minuten naar 90 seconden alleen door GROQ projections in generateStaticParams calls op te lossen.

// Traag: haalt alles op inclusief portable text, afbeeldingen, references
*[_type == "post"]

// Snel: alleen wat de listing pagina werkelijk nodig heeft
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

Die author->name inline dereference is kritiek. Het voorkomt het ophalen van het hele author document. Als je 3.000 posts hebt die elk naar een van de 50 authors verwijzen, is het verschil meetbaar.

Het Join-probleem waar niemand over praat

Sanity's GROQ documentatie toont dereferencing alsof het gratis is. Het is niet gratis. Elke -> in een query is eigenlijk een join. Stack drie of vier van hen in een list query die 100 resultaten retourneert en je zult het voelen.

We profillen nu elke GROQ query in onze projecten. Hier is onze vuistregel:

Patroon Documenten Gem. responstijd
Eenvoudig ophalen, geen refs 3.000 ~120ms
Eén niveau van -> dereference 3.000 ~250ms
Twee niveaus van -> 3.000 ~600ms
Geneste array met -> erin 3.000 ~1.200ms+

Dit zijn echte getallen van ons Sanity API dashboard in mid-2026. Je resultaten kunnen variëren op basis van documentgrootte, maar de trend is consistent.

GROQ patronen die we constant gebruiken

Voorwaardelijk ophalen voor preview vs. gepubliceerd:

*[_type == "post" && slug.current == $slug && ($preview || !(_id in path('drafts.**')))] [0] {
  ...,
  "author": author-> { name, slug, image },
  "categories": categories[]-> { title, slug }
}

Gepagineerde queries met count:

{
  "posts": *[_type == "post"] | order(publishedAt desc) [$start...$end] {
    _id, title, slug, publishedAt,
    "authorName": author->name
  },
  "total": count(*[_type == "post"])
}

Gerelateerde posts zonder 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
  }
}

Die related posts query is compact, maar draait server-side in Sanity's infrastructuur, dus het is meestal sneller dan twee round trips maken.

Studio Customizations die de investering waard zijn

Vanilla Sanity Studio is prima voor ontwikkelaars. Het is niet prima voor content teams die 20 posts per week verzenden. Dit customizen we op elk project.

Custom Document Actions

De standaard publish action triggert webhooks niet betrouwbaar in elke setup. We verpakken het:

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 revalidatie of deploy hook
        await fetch('/api/revalidate', {
          method: 'POST',
          body: JSON.stringify({ type: props.type, id: props.id }),
        })
      },
    }
  }
}

Structure Builder voor redactionele workflows

De standaard desk structure toont elk documenttype in een platte lijst. Bij 15+ documenttypes is dit chaos. We gebruiken Structure Builder om redactioneel gefocuste navigatie te creëren:

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('Gepubliceerde posts')
                .child(
                  S.documentList()
                    .title('Gepubliceerd')
                    .filter('_type == "post" && !(_id in path("drafts.**"))'
                ),
              S.listItem()
                .title('Concepten')
                .child(
                  S.documentList()
                    .title('Concepten')
                    .filter('_type == "post" && _id in path("drafts.**")'
                ),
              S.listItem()
                .title('Alle posts')
                .child(S.documentTypeList('post').title('Alle posts')),
            ])
        ),
      S.divider(),
      // ... andere contenttypen
    ])

Dit kost 30 minuten om in te stellen en bespaart editors uren verwarring elke week.

Portable Text Custom Components

Een ding dat ons hard raakte: editors die content uit Google Docs in de Portable Text editor plakken. De standaard block editor behandelt dit oké, maar custom block types hebben expliciete serializers nodig of ze verschijnen als lege vakken en editors raken in paniek.

We registreren custom components voor elk 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) + '...',
      }
    },
  },
})

Die preview config is klein maar essentieel. Zonder het zien editors lege blokken en weten niet wat ze zijn.

Sanity Studio Production Tips: Lessen van 3000+ Posts - architectuur

Content migratie en gegevensintegriteit

We hebben vijf grote content migraties naar Sanity gedaan -- van WordPress, Contentful, Prismic, markdown files, en een custom Rails CMS. Elk ervan leerde ons iets pijnlijks.

Gebruik de migratie tools, maar vertrouw en controleer

Sanity's @sanity/migrate package en de CLI's sanity documents import werken goed voor eenvoudige gevallen. Voor alles wat portable text conversie inhoudt, schrijf custom scripts. Altijd.

# Exporteer alles als backup voor elke migratie
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz

We voeren dit uit voor elke migratie, elke schema deploy, en eerlijk gezegd, elke maandag ochtend via cron. Datasets zijn goedkoop. Verloren content niet.

Schema versioning strategie

Sanity forceert schema versies niet op de datalaag. Dit is zowel een feature als een foot-gun. Oude documenten updaten niet magisch als je een schema verandert. We gebruiken een eenvoudig patroon:

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

Dan kunnen we in migratie scripts *[_type == "post" && schemaVersion < 2] opvragen en documenten in batch naar het nieuwe format updaten. Het is ruw maar het werkt.

Deployment en environment strategie

Sanity's datasetmodel ondersteunt meerdere omgevingen, en je zou ze van dag één moeten gebruiken -- niet na je eerste production data incident.

Onze standaard setup

Omgeving Dataset Studio URL Doel
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 clonen production naar staging wekelijks met sanity dataset copy production staging. Dit houdt staging realistisch zonder production data te risico tijdens schema experimenten.

Voor de frontend gebruiken onze Next.js development projecten omgevingsvariabelen om datasets te schakelen:

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. geen CDN

Sanity's API CDN is uiteindelijk consistent. Voor gepubliceerde content op een marketingsite is dit prima -- de CDN is snel en het staleness window is typisch onder 2 seconden. Voor preview/draft content, bypass altijd de CDN:

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

We hebben preview problemen gezien die uren debug duurden, alleen om te beseffen dat de preview client de CDN raakte en stale data toonde. Zet useCdn: false voor alle preview en draft-reading contexten.

Monitoring en debugging in production

GROQ query profiling

Sanity's management console (manage.sanity.io) toont API usage metrics, maar de granulariteit is niet altijd genoeg. We loggen slow queries aan de frontend kant:

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
}

Alles boven 500ms in production krijgt onderzoek. Meestal is het een unprojected query of een geneste dereference die door code review glipte.

Webhook betrouwbaarheid

Sanity webhooks zijn betrouwbaar maar niet onfeilbaar. We hebben occasionele gemiste webhooks gezien tijdens Sanity infrastructuurupdates. Voor kritieke workflows (zoals het triggeren van rebuilds op onze Astro development projecten), implementeren we een polling fallback:

// Controleer om de 5 minuten op recente wijzigingen als veiligheidsnet
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)

Prestatiegegevens van echte projecten

Hier zijn echte getallen van drie production projecten die we in 2024-2025 hebben gelanceerd met Sanity en headless frontends:

Metriek Project A (Next.js) Project B (Astro) Project C (Next.js)
Totaal documenten 3.200 1.800 4.100
Documenttypen 12 8 18
Gem. GROQ response (CDN) 85ms 72ms 130ms
Gem. GROQ response (geen CDN) 180ms 145ms 290ms
Volledige static build time 3m 20s 1m 45s 6m 10s
ISR revalidatie 1.2s N/A (static) 1.8s
Maandelijkse API requests ~450K ~180K ~1.2M
Sanity plan kosten/mnd Growth ($99) Free Growth ($99)

Project C's langere build time was volledig vanwege image processing, niet GROQ. Zodra we naar Sanity's image pipeline gingen met @sanity/image-url en juiste width/height parameters, stopte de build met het downloaden van full-resolution afbeeldingen.

Voor headless CMS development projecten is Sanity's pricing competitief. De free tier is echt bruikbaar voor kleinere sites. Het Growth plan op $99/maand dekt de meeste mid-size redactionele operaties. Je begint alleen kostenprobllemen te krijgen bij zeer hoge API request volumes, en zelfs dan houden agressief CDN gebruik en slimme caching dingen redelijk.

Wanneer Sanity niet de juiste keuze is

Ik zou je in de steek laten als ik niet de gevallen zou noemen waarin we klanten weg hebben geleid van Sanity:

  • Zeer relationele data (productcatalogi met complexe variant relaties) -- een doelgericht commerce platform of zelfs Postgres is logischer
  • Extreem non-technische teams die een WYSIWYG page builder nodig hebben -- Sanity's Portable Text is krachtig maar het is niet Squarespace
  • Budget-beperkte projecten met >200K maandelijkse API requests -- kosten kunnen je verrassen

Voor alles anders -- vooral redactionele content, marketing sites, en documentatie -- is Sanity onze go-to CMS geweest. Als je opties voor een headless project evalueert, neem dan contact met ons op en we geven je een eerlijke beoordeling op basis van je specifieke behoeften.

Veelgestelde vragen

Hoeveel documenten kan Sanity aan voordat performance degradeert? We hebben production projecten met meer dan 4.000 documenten gerund zonder betekenisvolle degradatie. Sanity's gehoste infrastructuur verwerkt documenttallen goed in de tienduizenden. De performance bottleneck is bijna altijd hoe je GROQ queries schrijft -- specifiek, unprojected fetches en diepe reference chains -- niet het ruwe documentaantal.

Moet ik GROQ of GraphQL gebruiken met Sanity? GROQ, tenzij je een zeer specifieke reden hebt om GraphQL te gebruiken. GROQ is expressiever voor Sanity's documentmodel, ondersteunt projections natuurlijker, en krijgt eerste-klas aandacht van het Sanity team. De GraphQL API wordt auto-gegenereerd uit je schema en werkt prima, maar je verliest enkele van de query flexibiliteit die Sanity krachtig maakt.

Hoe behandel je draft preview met Sanity en Next.js? We gebruiken Next.js Draft Mode gecombineerd met Sanity's perspective: 'previewDrafts' instelling. De preview client bypassed de CDN en gebruikt een read token. Sanity's @sanity/preview-kit package biedt real-time listeners die de pagina updaten terwijl editors typen. Het vergt wat setup maar het redactionele ervaring is het waard.

Wat is de beste manier om Portable Text voor SEO te structureren? Map je Portable Text block styles naar juiste semantische HTML. Gebruik h2, h3, h4 styles (niet alleen "grote tekst" of "heading"). Voeg custom block types toe voor gestructureerde data zoals FAQ secties, how-to stappen, en code blocks. We renderen Portable Text naar HTML met @portabletext/react met custom serializers die schema.org-vriendelijke markup outputten.

Hoe behandel je image optimization met Sanity? Sanity's image pipeline is uitstekend. Gebruik @sanity/image-url om URLs te genereren met specifieke dimensies en format parameters. Zet altijd auto=format om Sanity WebP of AVIF te laten serveren op basis van browser support. Voor Next.js projecten gebruiken we de Sanity image loader met next/image -- dit geeft je zowel Sanity's CDN als Next.js's built-in image optimization.

Kan Sanity geolocaliseerde/meertalige content op schaal behandelen? Ja, maar je schemaontwerp is enorm belangrijk. We gebruiken het document-level internationalisatie patroon (aparte documenten per locale gekoppeld door een gedeelde i18nId veld) in plaats van field-level translation objecten. Bij 3.000+ documenten over drie locales, houdt dit queries eenvoudig en voorkomt je de massieve documentgrootten die je krijgt als elk veld een object met 5+ taalsleutels bevat.

Hoe vaak moet je je Sanity API version updaten? Pinpoint je API version naar een specifieke datum (zoals 2026-01-01) en update het driemaandelijks na het bekijken van het changelog. Sanity's API versioning is op datum gebaseerd en breaking changes zijn zeldzaam, maar ze gebeuren. We zijn gebeten door ongedocumenteerd GROQ gedragswijzigingen tussen API versies -- test altijd je kritieke queries na het bumpen van de version.

Wat kost Sanity voor een groot redactioneel team? Het Growth plan op $99/maand (mid-2026) bevat 1M API requests, 500K API CDN requests, en 20 users. Voor de meeste redactionele teams die 20-50 posts per week publiceren, is dit meer dan voldoende. De primaire kostenfactor is API requests -- elke GROQ query van je frontend telt. Gebruik CDN agressief, cache waar mogelijk, en vermijd client-side fetches die vermenigvuldigen met traffic.