Dicas de Produção do Sanity Studio: Lições de 3000+ Posts

Executamos Sanity como nosso CMS principal em vários projetos de clientes por mais de três anos. Em algum lugar próximo à marca de 3.000 posts, você deixa de pensar em Sanity em termos do que a documentação diz e começa a pensar em termos do que realmente sobrevive à produção. Este artigo é esse despejo cerebral -- cada decisão de schema que lamentamos, cada consulta GROQ que derrubou uma build, e cada customização de Studio que fez editores realmente quererem usar o CMS em vez de nos enviar arquivos Word por email.

Este não é um guia de iniciantes. Se você está aqui, você provavelmente já configurou o Sanity Studio, criou alguns schemas e talvez tenha lançado um ou dois sites. O que quero compartilhar são os padrões que emergem apenas depois que você lida com equipes de conteúdo reais, fluxos de trabalho editoriais reais e orçamentos de desempenho reais em escala.

Índice

Sanity Studio Production Tips: Lessons from 3000+ Posts

Schema Design Que Sobrevive Equipes de Conteúdo Reais

O design de schema é onde a maioria dos projetos Sanity falha silenciosamente. Não de forma dramática e explosiva -- mais como uma erosão lenta da confiança editorial. A equipe de conteúdo começa a evitar certos campos. Eles criam workarounds. Seis meses depois, metade do seu conteúdo estruturado está na verdade amontoado em um único bloco de rich text porque o schema era "muito complicado".

Pare de Aninhar Objetos em Excesso

Nosso maior erro inicial foi criar estruturas de objetos profundamente aninhadas. Modelaríamos conteúdo como um schema de banco de dados -- normalizado, elegante, tecnicamente correto. Um post de blog tinha uma referência de author, que tinha um objeto bio, que tinha um array socialLinks de objetos, cada um com uma referência de platform.

Editores odiavam. Toda vez que precisavam atualizar o handle do Twitter de um autor, estavam cinco cliques de profundidade. Aqui está o que fazemos agora:

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

// Depois: Plano, amigável ao editor
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' }),
  ],
})

Sim, a versão plana é menos "pura". Também é usada corretamente 100% das vezes. Tradeoff aceito.

Use Field Groups Agressivamente

Uma vez que um tipo de documento tem mais de 8-10 campos, editores começam a rolar a página e perdem coisas. Os field groups do Sanity v3 são subestimados. Colocamos em cada tipo de documento com mais de seis campos:

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

Validação Que Orienta, Não Bloqueia

Aprendemos a pensar em validação como UX, não como enforcement. Validações required() duras em cada campo significa que editores não podem salvar rascunhos. Mensagens de validação customizadas que explicam por que algo importa obtêm conformidade muito melhor do que estados de erro genéricos:

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

Observe que é um warning, não um error. O editor ainda pode publicar. Eles apenas sabem as consequências.

Desempenho GROQ em Escala: O Que Realmente Importa

GROQ é maravilhoso até que não é. Em 500 documentos, tudo é rápido. Em 3.000+ documentos com referências, imagens e portable text, você começa a notar coisas.

Projections Não São Opcionais

O único maior alavanca de desempenho GROQ é projections. Pare de buscar documentos inteiros quando você só precisa de três campos. Já vi builds do Next.js ir de 4 minutos para 90 segundos apenas corrigindo projeções GROQ em chamadas de generateStaticParams.

// Lento: busca tudo incluindo portable text, imagens, referências
*[_type == "post"]

// Rápido: apenas o que a página de listagem realmente precisa
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

Aquele author->name inline dereference é crítico. Evita buscar o documento do author inteiro. Quando você tem 3.000 posts cada um referenciando um de 50 autores, a diferença é mensurável.

O Problema de Join que Ninguém Fala

A documentação GROQ do Sanity mostra dereferencing como se fosse gratuito. Não é. Cada -> em uma consulta é essencialmente um join. Empilhe três ou quatro deles em uma consulta de listagem que retorna 100 resultados e você sentirá.

Nós fazemos profiling de cada consulta GROQ em nossos projetos agora. Aqui está nossa regra de ouro:

Padrão Documentos Tempo de Resposta Médio
Fetch simples, sem refs 3.000 ~120ms
Um nível de dereference -> 3.000 ~250ms
Dois níveis de -> 3.000 ~600ms
Array aninhado com -> dentro 3.000 ~1.200ms+

Estes são números reais do nosso dashboard de API Sanity em meados de 2025. Seus números podem variar dependendo do tamanho do documento, mas a tendência é consistente.

Padrões GROQ Que Usamos Constantemente

Fetch condicional para preview vs. publicado:

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

Consultas paginadas com contagem:

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

Posts relacionados sem 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
  }
}

Aquela consulta de posts relacionados é densa, mas é executada no lado do servidor na infraestrutura do Sanity, então geralmente é mais rápida do que fazer duas rodadas.

Customizações de Studio Que Valem o Investimento

O Sanity Studio vanilla é bom para desenvolvedores. Não é bom para equipes de conteúdo que entregam 20 posts por semana. Aqui está o que customizamos em cada projeto.

Ações de Documento Customizadas

A ação de publicação padrão não dispara webhooks de forma confiável em todas as configurações. Nós a envolvemos:

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 para Fluxos de Trabalho Editorial

A estrutura de desk padrão mostra cada tipo de documento em uma lista plana. Com 15+ tipos de documento, isso é caos. Usamos Structure Builder para criar navegação focada em editorial:

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
    ])

Isso leva 30 minutos para configurar e economiza horas de confusão dos editores toda semana.

Componentes Customizados de Portable Text

Uma coisa que nos prejudicou muito: editores colando conteúdo do Google Docs no editor Portable Text. O editor de bloco padrão lida com isso bem, mas tipos de bloco customizados precisam de serializadores explícitos ou aparecem como caixas vazias e editores entram em pânico.

Registramos componentes customizados para cada tipo de bloco:

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

Aquele config preview é pequeno mas essencial. Sem ele, editores veem blocos vazios e não sabem o que são.

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

Migração de Conteúdo e Integridade de Dados

Fizemos cinco migrações de conteúdo principais para Sanity -- do WordPress, Contentful, Prismic, arquivos markdown e um CMS Rails customizado. Cada uma delas nos ensinou algo doloroso.

Use as Ferramentas de Migração, Mas Confie e Verifique

O pacote @sanity/migrate do Sanity e o CLI sanity documents import funcionam bem para casos simples. Para qualquer coisa envolvendo conversão de portable text, escreva scripts customizados. Sempre.

# Exporte tudo para backup antes de qualquer migração
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz

Executamos isso antes de cada migração, cada deploy de schema e honestamente, toda segunda-feira de manhã via cron. Datasets são baratos. Conteúdo perdido não é.

Estratégia de Versionamento de Schema

Sanity não força versões de schema na camada de dados. Isso é tanto um recurso quanto uma armadilha. Documentos antigos não se atualizam magicamente quando você muda um schema. Usamos um padrão simples:

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

Então em scripts de migração, podemos consultar *[_type == "post" && schemaVersion < 2] e fazer batch-update de documentos para o novo formato. É rudimentar mas funciona.

Estratégia de Deployment e Ambiente

O modelo de dataset do Sanity suporta múltiplos ambientes, e você deve usá-los desde o primeiro dia -- não depois do seu primeiro incidente de dados em produção.

Nossa Configuração Padrão

Ambiente Dataset Studio URL Propósito
Production production studio.client.com Edição de conteúdo ao vivo
Staging staging staging-studio.client.com QA de conteúdo, teste de schema
Development development localhost:3333 Desenvolvimento de schema

Clonamos production para staging semanalmente usando sanity dataset copy production staging. Isso mantém staging realista sem arriscar dados de produção durante experimentos de schema.

Para o frontend, nossos projetos Next.js development usam variáveis de ambiente para trocar 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. Sem CDN

O CDN da API Sanity é eventualmente consistente. Para conteúdo publicado em um site de marketing, isso é bom -- o CDN é rápido e a janela de staleness é tipicamente menor que 2 segundos. Para conteúdo preview/draft, sempre passe pelo CDN:

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

Vimos problemas de preview que levaram horas para debugar, apenas para descobrir que o cliente preview estava acertando o CDN e mostrando dados stale. Configure useCdn: false para todos os contextos de leitura de preview e draft.

Monitoramento e Debugging em Produção

Profiling de Consultas GROQ

O console de gerenciamento do Sanity (manage.sanity.io) mostra métricas de uso de API, mas a granularidade nem sempre é suficiente. Registramos consultas lentas no lado do frontend:

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
}

Qualquer coisa acima de 500ms em produção é investigada. Geralmente é uma consulta não-projetada ou um dereference aninhado que passou pela revisão de código.

Confiabilidade de Webhook

Webhooks do Sanity são confiáveis mas não infalíveis. Vimos webhooks ocasionalmente perdidos durante atualizações de infraestrutura do Sanity. Para fluxos de trabalho críticos (como disparar rebuilds em nossos projetos Astro development), implementamos um fallback de polling:

// Verifique mudanças recentes a cada 5 minutos como uma rede de segurança
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)

Benchmarks de Desempenho de Projetos Reais

Aqui estão números reais de três projetos de produção que lançamos em 2024-2025 usando Sanity com frontends headless:

Métrica Projeto A (Next.js) Projeto B (Astro) Projeto C (Next.js)
Documentos totais 3.200 1.800 4.100
Tipos de documento 12 8 18
Resposta GROQ média (CDN) 85ms 72ms 130ms
Resposta GROQ média (sem CDN) 180ms 145ms 290ms
Tempo de build estático completo 3m 20s 1m 45s 6m 10s
Revalidação ISR 1.2s N/A (static) 1.8s
Requisições de API mensais ~450K ~180K ~1.2M
Custo plano Sanity/mês Growth ($99) Free Growth ($99)

O tempo de build mais longo do Projeto C foi inteiramente devido ao processamento de imagens, não GROQ. Uma vez que migramos para o pipeline de imagens do Sanity com @sanity/image-url e parâmetros apropriados de width/height, o build parou de baixar imagens em resolução completa.

Para projetos headless CMS development, a precificação do Sanity é competitiva. O tier gratuito é genuinamente usável para sites menores. O plano Growth em $99/mês cobre a maioria das operações editoriais de médio porte. Você só começa a se preocupar com custos em volumes muito altos de requisições de API, e mesmo assim, uso agressivo de CDN e caching inteligente mantêm as coisas razoáveis.

Quando Sanity Não É a Escolha Certa

Eu faria um desserviço se não mencionasse os casos em que desaconselhamos clientes a usar Sanity:

  • Dados altamente relacionais (catálogos de produtos com relacionamentos complexos de variantes) -- uma plataforma de comércio dedicada ou até Postgres faz mais sentido
  • Equipes extremamente não-técnicas que precisam de um page builder WYSIWYG -- o Portable Text do Sanity é poderoso mas não é Squarespace
  • Projetos com orçamento limitado com >200K requisições de API mensais -- custos podem surpreender você

Para tudo mais -- especialmente conteúdo editorial, sites de marketing e documentação -- Sanity tem sido nosso CMS padrão. Se você está avaliando opções para um projeto headless, entre em contato conosco e forneceremos uma avaliação honesta baseada em suas necessidades específicas.

FAQ

Quantos documentos o Sanity pode lidar antes do desempenho degradar? Executamos projetos de produção com mais de 4.000 documentos sem degradação significativa. A infraestrutura hospedada do Sanity lida bem com contagens de documentos na casa dos dezenas de milhares. O gargalo de desempenho é quase sempre em como você escreve consultas GROQ -- especificamente, fetches não-projetadas e cadeias de referência profundas -- não a contagem bruta de documentos.

Devo usar GROQ ou GraphQL com Sanity? GROQ, a menos que você tenha uma razão muito específica para usar GraphQL. GROQ é mais expressivo para o modelo de documento do Sanity, suporta projeções mais naturalmente e recebe atenção de primeira classe da equipe do Sanity. A API GraphQL é auto-gerada a partir do seu schema e funciona bem, mas você perde alguma da flexibilidade de consulta que torna Sanity poderoso.

Como você lida com preview de draft com Sanity e Next.js? Usamos Next.js Draft Mode combinado com a configuração perspective: 'previewDrafts' do Sanity. O cliente de preview pula o CDN e usa um token de leitura. O pacote @sanity/preview-kit do Sanity fornece listeners em tempo real que atualizam a página conforme editores digitam. Requer alguma configuração mas a experiência editorial vale a pena.

Qual é a melhor forma de estruturar Portable Text para SEO? Mapeie seus estilos de bloco Portable Text para HTML semântico apropriado. Use estilos h2, h3, h4 (não apenas "large text" ou "heading"). Adicione tipos de bloco customizados para dados estruturados como seções de FAQ, passos de how-to e blocos de código. Renderizamos Portable Text para HTML usando @portabletext/react com serializadores customizados que produzem markup amigável ao schema.org.

Como você lida com otimização de imagem com Sanity? O pipeline de imagem do Sanity é excelente. Use @sanity/image-url para gerar URLs com dimensões específicas e parâmetros de formato. Sempre configure auto=format para deixar Sanity servir WebP ou AVIF baseado em suporte de navegador. Para projetos Next.js, usamos o loader de imagem Sanity com next/image -- isso fornece tanto o CDN do Sanity quanto a otimização de imagem integrada do Next.js.

Sanity pode lidar com conteúdo localizado/multilíngue em escala? Sim, mas o design do seu schema importa enormemente. Usamos o padrão de internacionalização em nível de documento (documentos separados por locale vinculados por um campo compartilhado i18nId) em vez de objetos de tradução em nível de campo. Em 3.000+ documentos em três locales, isso mantém consultas simples e evita os tamanhos massivos de documentos que você obtém quando cada campo contém um objeto com 5+ chaves de idioma.

Com que frequência você deve atualizar sua versão de API do Sanity? Fixe sua versão de API a uma data específica (como 2025-01-01) e atualize trimestralmente após revisar o changelog. O versionamento de API do Sanity é baseado em data e breaking changes são raras, mas acontecem. Já fomos prejudicados por mudanças de comportamento GROQ não-documentadas entre versões de API -- sempre teste suas consultas críticas depois de atualizar a versão.

Qual é o custo do Sanity para uma grande equipe editorial? O plano Growth em $99/mês (a partir de meados de 2025) inclui 1M requisições de API, 500K requisições de API CDN e 20 usuários. Para a maioria das equipes editoriais publicando 20-50 posts por semana, isso é mais que suficiente. O driver de custo primário é requisições de API -- cada consulta GROQ do seu frontend conta. Use CDN agressivamente, faça cache onde possível e evite fetches do lado do cliente que se multiplicam com tráfego.