Sanity Studio em 3000+ Posts: GROQ, Schemas & Sobrevivência em Produção
Sua build Sanity trava em 2.400 documentos. A barra de progresso congela. Uma query GROQ que rodava em menos de 200ms no seu ambiente local agora expira no Vercel. Um editor te envia um doc do Word com alterações rastreadas em vez de abrir o Studio. Lançamos 3.000+ posts em projetos de clientes nos últimos três anos, e em algum ponto após o limiar de 1.500 documentos, as regras mudam. Os padrões de schema que a documentação recomenda começam a falhar. Cadeias de referências que você achava elegantes viram armadilhas em tempo de build. Customizações de Studio que pareciam geniais no mês um viram reclamações de editores no mês seis. O que vem a seguir não é teoria -- são as decisões de schema que voltamos atrás, as reescritas GROQ que reduziram o tempo de build em 70%, e os três ajustes de Studio que pararam os emails com docs do Word.
Isso não é um guia para iniciantes. Se você está aqui, provavelmente já configurou o Sanity Studio, criou alguns schemas, e talvez tenha lançado um site ou dois. O que quero compartilhar são os padrões que só emergem depois que você lida com equipes de conteúdo reais, fluxos editoriais reais, e orçamentos de performance reais em escala.
Índice
- Design de Schema que Sobrevive a Equipes de Conteúdo Reais
- Performance GROQ em Escala: O Que Realmente Importa
- Customizações de Studio Que Valem o Investimento
- Migração de Conteúdo e Integridade de Dados
- Estratégia de Deploy e Ambiente
- Monitoramento e Debug em Produção
- Benchmarks de Performance de Projetos Reais
- FAQ

Design de Schema que Sobrevive a Equipes de Conteúdo Reais
Design de schema é onde a maioria dos projetos Sanity falha silenciosamente. Não de um jeito dramático de desastre total -- 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 Demais
Nosso maior erro inicial foi criar estruturas de objeto profundamente aninhadas. Modelávamos conteúdo como um schema de banco de dados -- normalizado, elegante, tecnicamente correto. Um post de blog tinha uma referência a author, que tinha um objeto bio, que tinha um array socialLinks de objetos, cada um com uma referência a platform.
Editores odiavam. Toda vez que precisavam atualizar o handle do Twitter de um author, 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: 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' }),
],
})
Sim, a versão flat é menos "pura". Também é usada corretamente 100% das vezes. Trade-off aceito.
Use Field Groups Agressivamente
Uma vez que um tipo de documento tem mais de 8-10 campos, editores começam a rolar e perdem coisas. Field groups do Sanity v3 são subestimados. Colocamos em todo 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 Guia, Não Que 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 conseguem salvar rascunhos. Mensagens de validação customizadas que explicam por que algo importa conquistam muito mais conformidade 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.'),
})
Note que é um warning, não um error. O editor ainda consegue publicar. Ele apenas sabe as consequências.
Performance GROQ em Escala: O Que Realmente Importa
GROQ é maravilhoso até quando 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 maior leverage de performance GROQ é projections. Pare de buscar documentos inteiros quando você só precisa de três campos. Já vi builds Next.js ir de 4 minutos para 90 segundos apenas corrigindo projections GROQ em chamadas generateStaticParams.
// Slow: busca tudo incluindo portable text, imagens, referências
*[_type == "post"]
// Fast: só 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
}
Essa desreferência author->name inline é crítica. Evita buscar o documento de author inteiro. Quando você tem 3.000 posts cada um referenciando um de 50 authors, a diferença é mensurável.
O Problema de Join Que Ninguém Fala
A documentação GROQ do Sanity mostra desreferencing como se fosse grátis. Não é. Cada -> em uma query é essencialmente um join. Empilhe três ou quatro deles em uma query de listagem que retorna 100 resultados e você vai sentir.
Nós fazemos profile de toda query GROQ em nossos projetos agora. Aqui está nossa regra de ouro:
| Padrão | Documentos | Tempo Médio de Resposta |
|---|---|---|
| Fetch simples, sem refs | 3.000 | ~120ms |
Um nível de desreferência -> |
3.000 | ~250ms |
Dois níveis de -> |
3.000 | ~600ms |
Array aninhado com -> dentro |
3.000 | ~1.200ms+ |
Esses são números reais do dashboard API Sanity no mid-2026. Seus resultados vão variar baseado no 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 }
}
Queries paginadas com count:
{
"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
}
}
Essa query de posts relacionados é densa, mas roda no lado servidor na infraestrutura Sanity, então geralmente é mais rápida do que fazer dois round trips.
Customizações de Studio Que Valem o Investimento
Vanilla Sanity Studio é bom para developers. Não é bom para equipes de conteúdo lançando 20 posts por semana. Aqui está o que customizamos em todo projeto.
Custom Document Actions
A ação de publicação padrão não dispara webhooks de forma confiável em todo setup. 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()
// Dispara revalidação ISR ou deploy hook
await fetch('/api/revalidate', {
method: 'POST',
body: JSON.stringify({ type: props.type, id: props.id }),
})
},
}
}
}
Structure Builder para Fluxos Editoriais
A desk structure padrão mostra cada tipo de documento em uma lista flat. 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(),
// ... outros tipos de conteúdo
])
Isso leva 30 minutos para configurar e economiza horas de confusão de editores toda semana.
Componentes Custom de Portable Text
Uma coisa que nos mordeu: editores colando conteúdo do Google Docs no editor Portable Text. O editor de bloco padrão lida com isso ok, mas tipos de bloco customizados precisam de serializers 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) + '...',
}
},
},
})
Esse config preview é pequeno mas essencial. Sem ele, editores veem blocos em branco e não sabem o que são.

Migração de Conteúdo e Integridade de Dados
Fazemos cinco grandes migrações de conteúdo para Sanity -- de WordPress, Contentful, Prismic, arquivos markdown, e um CMS Rails customizado. Cada uma nos ensinou algo doloroso.
Use a Ferramenta de Migração, Mas Confira e Verifique
O pacote @sanity/migrate do Sanity e o sanity documents import do CLI funcionam bem para casos diretos. 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
Rodamos isso antes de toda migração, todo 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 uma feature quanto um foot-gun. 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,
})
Aí em scripts de migração, podemos fazer query *[_type == "post" && schemaVersion < 2] e batch-update documentos para o novo formato. É crude mas funciona.
Estratégia de Deploy e Ambiente
O modelo de dataset do Sanity suporta múltiplos ambientes, e você deveria usá-los desde o dia um -- não depois do seu primeiro incidente de dados em produção.
Nossa Setup 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 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: '2026-01-01',
useCdn: process.env.NODE_ENV === 'production',
}
CDN vs. Sem CDN
A API CDN do Sanity é eventualmente consistente. Para conteúdo publicado em um site de marketing, isso é fine -- o CDN é rápido e a janela de staleness é tipicamente menos de 2 segundos. Para conteúdo preview/draft, sempre bypass o CDN:
const client = sanityClient.withConfig({
useCdn: false,
token: process.env.SANITY_PREVIEW_TOKEN,
perspective: 'previewDrafts',
})
Viemos issue de preview que levou horas para debugar, só para perceber que o cliente de preview estava batendo no CDN e mostrando dados stale. Sete useCdn: false para todos os contextos de leitura de preview e draft.
Monitoramento e Debug em Produção
GROQ Query Profiling
O console de management do Sanity (manage.sanity.io) mostra métricas de uso de API, mas a granularidade nem sempre é suficiente. Logamos queries 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 query sem projection ou uma desreferência aninhada que passou pelo code review.
Confiabilidade de Webhook
Webhooks do Sanity são confiáveis mas não infalíveis. Vimos ocasionais webhooks perdidos durante updates de infraestrutura do Sanity. Para fluxos críticos (como disparar rebuilds em nossos projetos), implementamos um fallback de polling:
// Verifique por mudanças recentes a cada 5 minutos como 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 Performance 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) |
|---|---|---|---|
| Total de documentos | 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 (estático) | 1.8s |
| Requisições de API mensais | ~450K | ~180K | ~1.2M |
| Custo de plano Sanity/mês | Growth ($99) | Free | Growth ($99) |
O tempo de build mais longo do Projeto C foi inteiramente devido ao processamento de imagem, não GROQ. Uma vez que movemos para o pipeline de imagem do Sanity com @sanity/image-url e parâmetros próprios de width/height, o build parou de baixar imagens em resolução completa.
Para projetos de desenvolvimento headless CMS, o pricing do Sanity é competitivo. O tier free é genuinamente usável para sites menores. O plano Growth a $99/mês cobre a maioria das operações editoriais de tamanho médio. Você só começa a notar preocupações de custo 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 estaria fazendo você um desserviço se não mencionasse os casos onde desviamos clientes do Sanity:
- Dados altamente relacionais (catálogos de produto com relacionamentos complexos de variantes) -- uma plataforma de commerce específica ou até Postgres faz mais sentido
- Equipes extremamente não-técnicas que precisam de um page builder WYSIWYG -- Portable Text do Sanity é poderoso mas não é Squarespace
- Projetos com orçamento restrito com >200K requisições mensais de API -- custos podem surpreender você
Para todo o resto -- especialmente conteúdo editorial, sites de marketing, e documentação -- Sanity tem sido nosso go-to CMS. Se você está avaliando opções para um projeto headless, nos contacte e daremos uma avaliação honesta baseada em suas necessidades específicas.
FAQ
Quantos documentos Sanity consegue lidar antes que performance degrade?
Rodamos projetos de produção com mais de 4.000 documentos sem degradação significativa. A infraestrutura hospedada do Sanity lida bem com contagens de documento bem nos dezenas de milhares. O gargalo de performance é quase sempre em como você escreve queries GROQ -- especificamente, fetches sem projection 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 projections mais naturalmente, e consegue atenção de primeira classe do time Sanity. A API GraphQL é auto-gerada do seu schema e funciona fine, mas você perde parte da flexibilidade de query 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 bypassa o CDN e usa um token de read. O pacote @sanity/preview-kit do Sanity fornece listeners em tempo real que atualizam a página enquanto editores digitam. Leva um tempo para setup mas a experiência editorial vale.
Qual é a melhor maneira 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 "texto grande" ou "heading"). Adicione tipos de bloco customizados para dados estruturados como seções FAQ, passos how-to, e blocos de código. Renderizamos Portable Text para HTML usando @portabletext/react com serializers customizados que outputam markup amigável a 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 o Sanity servir WebP ou AVIF baseado em suporte do navegador. Para projetos Next.js, usamos o image loader Sanity com next/image -- isso te dá tanto o CDN do Sanity quanto a otimização de imagem built-in do Next.js.
Sanity consegue lidar com conteúdo localizado/multilíngue em escala?
Sim, mas seu design de schema importa enormemente. Usamos o padrão de internacionalização em nível de documento (documentos separados por locale linkados por um campo i18nId compartilhado) em vez de objetos de tradução em nível de campo. Em 3.000+ documentos em três locales, isso mantém queries simples e evita os documentos de tamanho massivo que você consegue quando cada campo contém um objeto com 5+ chaves de linguagem.
Com que frequência você deveria atualizar sua versão de API Sanity?
Fixe sua versão de API em uma data específica (como 2026-01-01) e atualize trimestralmente após revisar o changelog. O versionamento de API do Sanity é baseado em data e breaking changes são raros, mas elas acontecem. Fomos mordidos por mudanças de comportamento GROQ não-documentadas entre versões de API -- sempre teste suas queries críticas depois de bumpar a versão.
Qual é o custo do Sanity para uma grande equipe editorial?
O plano Growth a $99/mês (a partir de mid-2026) 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 do que suficiente. O driver de custo primário é requisições de API -- toda query GROQ do seu frontend conta. Use CDN agressivamente, cache onde possível, e evite fetches no lado cliente que se multiplicam com tráfego.