We voeren Sanity al meer dan drie jaar uit als ons primaire CMS voor meerdere klantprojecten. Ergens rond de 3.000-post-grens stop je met nadenken over Sanity in termen van wat de documentatie zegt en begin je nadenken in termen van wat daadwerkelijk in productie overleeft. Dit artikel is die complete breindump -- elke schema-keuze die we betreuren, elk GROQ-query dat een build om zeep hielp, en elke Studio-aanpassing die editors daadwerkelijk enthousiast maakte om het CMS te gebruiken in plaats van ons Word-documenten per e-mail te sturen.

Dit is geen getting-started gids. Als je hier bent, heb je waarschijnlijk al Sanity Studio opgezet, een paar schema's aangemaakt en misschien één of twee sites gelanceerd. Wat ik wil delen zijn de patronen die alleen naar voren komen nadat je hebt omgegaan met echte content teams, echte redactionele workflows en echte performance-budgetten op schaal.

Inhoudsopgave

Sanity Studio Production Tips: Lessons from 3000+ Posts

Schema-ontwerp dat echte content teams overleeft

Schema-ontwerp is waar de meeste Sanity-projecten stil falen. Niet op een dramatische manier -- meer als een geleidelijke erosie van redactioneel vertrouwen. Het content team begint bepaalde velden te vermijden. Ze creëren workarounds. Zes maanden later zit de helft van je gestructureerde content feitelijk opgepropt in een enkel rich text blok omdat het schema "te ingewikkeld" was.

Stop met overmatig nesten van objecten

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

Editors hatten het. Elke keer dat ze een Twitter-handle van een auteur moesten updaten, waren ze vijf klikken diep. Dit is wat we nu doen:

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

// 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 "puur". 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 over het hoofd te zien. 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: 'Instellingen' },
  ],
  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' }),
  ],
})

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

Validatie die leidt, niet blokkeert

We leerden validatie als UX te zien, niet als enforcement. Harde required() validaties op elk veld betekent dat editors concepten niet kunnen opslaan. Custom validatiemeldingen die uitleggen waarom iets belangrijk is, krijgen veel beter gehoor dan generieke foutstatussen:

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

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

GROQ Performance op schaal: wat echt uitmaakt

GROQ is wonderbaarlijk tot het niet meer is. Met 500 documenten is alles snel. Met meer dan 3.000 documenten met referenties, afbeeldingen en portable text merk je dingen.

Projections zijn niet optioneel

De enige grootste GROQ performance-hefboom is projections. Stop met het ophalen van volledige documenten wanneer je alleen drie velden nodig hebt. Ik heb Next.js builds zien gaan van 4 minuten naar 90 seconden alleen door GROQ projections in generateStaticParams aanroepen op te lossen.

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

// Snel: alleen wat de lijstpagina echt 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 cruciaal. Het voorkomt het ophalen van het volledige author document. Wanneer je 3.000 posts hebt die elk verwijzen naar één van 50 auteurs, is het verschil meetbaar.

Het Join-probleem waar niemand over praat

De GROQ-documentatie van Sanity toont dereferencing alsof het gratis is. Dat is het niet. Elke -> in een query is in wezen een join. Stack drie of vier ervan in een lijstquery die 100 resultaten retourneert en je voelt het.

We profileren elk GROQ-query in onze projecten nu. 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 nummers van ons Sanity API-dashboard in mid-2025. Jouw 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
  }
}

Dat gerelateerde posts-query is compact, maar het loopt server-side in Sanity's infrastructuur, dus het is meestal sneller dan twee trips maken.

Studio-aanpassingen die de investering waard zijn

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

Custom Document Actions

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

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. Met 15+ documenttypes is dit chaos. We gebruiken Structure Builder om navigatie gericht op redactie 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 content types
    ])

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

Portable Text Custom Components

Iets dat ons hard raakte: editors kopiëren content van Google Docs naar de Portable Text editor. De standaard block editor gaat hier oké mee om, 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 blanco blokken en weten niet wat ze zijn.

Sanity Studio Production Tips: Lessons from 3000+ Posts - architecture

Content migratie en data-integriteit

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

Gebruik de Migration Tooling, maar controleer en verifieer

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

# Exporteer alles voor back-up 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 eigenlijk, elke maandagochtend via cron. Datasets zijn goedkoop. Verloren content niet.

Schema Versioning Strategie

Sanity dwingt schema versies niet af op de data layer. Dit is zowel een feature als een foot-gun. Oude documenten updaten niet automatisch wanneer je een schema wijzigt. We gebruiken een eenvoudig patroon:

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

Dan in migratiescripts kunnen we *[_type == "post" && schemaVersion < 2] opvragen en batch-updaten van documenten naar het nieuwe formaat. Het is grof maar het werkt.

Deployment en omgevingsstrategie

Sanity's dataset model ondersteunt meerdere omgevingen, en je moet ze vanaf dag één gebruiken -- niet na je eerste production data incident.

Onze standaard setup

Omgeving Dataset Studio URL Doel
Productie production studio.client.com Live content editing
Staging staging staging-studio.client.com Content QA, schema testing
Development development localhost:3333 Schema development

We klonen productie 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 om te schakelen:

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

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

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

We hebben preview-problemen gezien die uren debuggen kostten, alleen om te realiseren dat de preview client de CDN raakte en verouderde data toonde. Stel useCdn: false in voor alle preview en draft-lezende contexten.

Monitoring en debugging in productie

GROQ Query Profiling

Sanity's management console (manage.sanity.io) toont API usage metrics, maar de granulariteit is niet altijd voldoende. We loggen trage 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 productie wordt onderzocht. Meestal is het een un-projected query of een geneste dereference die code review door kwam.

Webhook Betrouwbaarheid

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

// Controleer op recente wijzigingen elke 5 minuten 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)

Performance benchmarks van echte projecten

Hier zijn echte nummers van drie production projecten die we in 2024-2025 hebben verzonden met behulp van Sanity met headless frontends:

Metriek Project A (Next.js) Project B (Astro) Project C (Next.js)
Totale documenten 3.200 1.800 4.100
Documenttypes 12 8 18
Gem. GROQ respons (CDN) 85ms 72ms 130ms
Gem. GROQ respons (geen CDN) 180ms 145ms 290ms
Volledige static build tijd 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 tijd was volledig vanwege beeldverwerking, niet GROQ. Zodra we naar Sanity's image pipeline met @sanity/image-url en juiste width/height parameters verhuisden, stopte de build met het downloaden van afbeeldingen in volledige resolutie.

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

Wanneer Sanity niet de juiste keuze is

Ik zou je een slechte dienst bewijzen als ik niet de gevallen noemen waarin we clients weg hebben geleid van Sanity:

  • Zeer relationele data (productcatalogi met complexe variant relaties) -- een purpose-built commerce platform of zelfs Postgres heeft meer zin
  • Extreem niet-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 editoriale content, marketing sites en documentatie -- is Sanity onze go-to CMS. Als je opties evalueert voor een headless project, neem contact met ons op en we geven je een eerlijke beoordeling op basis van jouw specifieke behoeften.

Veelgestelde vragen

Hoeveel documenten kan Sanity aan voordat performance verslechtert? We voeren production projecten met meer dan 4.000 documenten uit zonder betekenisvolle verslechtering. Sanity's gehoste infrastructuur verwerkt documentaantallen goed tot tienduizenden. De performance bottleneck is bijna altijd hoe je GROQ queries schrijft -- specifiek, un-projected fetches en diepe referentie ketens -- 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 expressief voor Sanity's documentmodel, ondersteunt projections natuurlijker, en krijgt eerste-klas aandacht van het Sanity team. De GraphQL API wordt auto-gegenereerd vanuit je schema en werkt prima, maar je verliest wat 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' setting. De preview client omzeilt 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 de editoriale ervaring is het waard.

Wat is de beste manier om Portable Text voor SEO in te richten? 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 blokken. We renderen Portable Text naar HTML met behulp van @portabletext/react met custom serializers die schema.org-vriendelijke markup genereren.

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

Kan Sanity gelokaliseerde/meertalige content op schaal verwerken? Ja, maar je schema ontwerp 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. Met 3.000+ documenten over drie locales houdt dit queries eenvoudig en voorkomt je de massieve documentgroottes die je krijgt wanneer elk veld een object met 5+ taak keys bevat.

Hoe vaak moet je je Sanity API versie updaten? Pin je API versie naar een specifieke datum (zoals 2025-01-01) en update het driemaandelijks na het beoordelen van het changelog. Sanity's API versioning is op datums gebaseerd en breaking changes zijn zeldzaam, maar ze gebeuren. We zijn gebeten door undocumented GROQ gedrag veranderingen tussen API versies -- test altijd je kritieke queries na het bumpen van de versie.

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