Os componentes React do seu CMS não deveriam buscar os próprios dados

headless-cms

Cedo ou tarde, todo headless CMS lança um kit para React, e a maioria é igual: envolva seu app num provider, coloque um <BlogList />, e o componente busca os próprios posts. É uma demo agradável. Era um design razoável quando todo app React era uma single-page app.

Hoje é o design errado. Com React Server Components, loaders de rota no TanStack Start e no React Router e renderização no servidor como padrão em todo framework sério, um componente que busca os próprios dados briga com o framework em que roda. Acabei de lançar componentes de blog, registro de alterações, central de ajuda e roteiro para o Kookee, e os construí em torno da regra oposta. Abaixo vão o raciocínio e as seis regras que saíram dele.

O que dá errado quando o componente busca os dados

Veja o formato clássico:

tsx
<CmsProvider apiKey="…">
  <BlogList limit={10} />
</CmsProvider>

O BlogList chama um hook, o hook busca os dados num effect, e a lista renderiza quando os dados chegam. Daí saem quatro consequências, nenhuma boa.

O conteúdo não está no HTML. Effects não rodam no servidor. O primeiro HTML que um visitante ou um crawler recebe tem um spinner onde deveriam estar os posts. Os kits contornam isso com uma etapa própria de prefetch, que é o carregamento de dados do framework reinventado dentro de um componente.

Tudo vira client component. Um componente que chama hooks precisa rodar no navegador, então a lista inteira, os cards e o markup vão como JavaScript, para um conteúdo que nunca muda depois de renderizado.

As requisições acontecem no lugar errado. Seu framework já sabe buscar dados: num Server Component, num loader, com cache e revalidação próprios. Um componente que busca na montagem passa por cima de tudo isso, e componentes aninhados que buscam cada um na própria montagem carregam um depois do outro, em vez de em paralelo.

O conteúdo atravessa a rede duas vezes. No App Router do Next.js, cada prop de um client component é serializada na página para o React poder hidratá-lo. Passe um post inteiro a um client component, e o corpo do artigo chega uma vez como HTML e outra vez dentro dos dados da página.

Regra 1: receba os dados via props

Um componente deveria ser uma função dos dados. Ele não deveria saber de onde os dados vieram.

tsx
// app/blog/page.tsx, um Server Component
const { data } = await kookee.blog.list({ limit: 12 });

return <BlogPostList posts={data} href="/blog/{slug}" />;

A busca acontece onde seu framework quer: no Server Component, no loader ou, numa single-page app, num hook cujo data você passa direto. O mesmo componente serve aos três casos. Ele não precisa de provider, não tem estado de carregamento próprio, e o cache é o que seu framework já faz.

Isso também deixa o componente trivial de testar. Passe um array, olhe o markup.

Regra 2: servidor por padrão, cliente só onde o navegador é necessário

Quase tudo numa página de conteúdo é estático: listas, cards, artigos, breadcrumbs, cabeçalhos. Muito pouco precisa do navegador. Num blog, são os botões de reação e um índice que destaca a seção que está sendo lida. Numa central de ajuda, uma caixa de busca e o voto “Este artigo foi útil?”.

Por isso cada área com partes interativas tem dois caminhos de import:

  • @kookee/react/help renderiza no servidor, sem estado e sem APIs do navegador;

  • @kookee/react/help/client, como os outros caminhos /client, reúne as partes interativas, marcadas com 'use client'.

Com Server Components, uma página renderiza quase tudo no servidor e hidrata só as poucas ilhas que precisam disso.

A divisão é garantida pelo build, não só documentada. Uma verificação roda sobre os arquivos que publico e falha se um ponto de entrada de servidor cria um contexto React, está marcado com 'use client' ou importa um hook que o React não exporta para Server Components. O React exporta poucos hooks ali, useMemo, useCallback, useId e useDebugValue, e esses quatro são os únicos que a verificação deixa passar. A mesma verificação reprova qualquer ponto de entrada de componente que importe algo além do React e do SDK ou passe de 10 kB com gzip.

Regra 3: client components recebem primitivos

As partes interativas são client components, então as props delas são serializadas na página. Elas recebem o menor dado que resolve.

Os botões de reação não recebem um post. Recebem o id do post e as contagens:

tsx
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />

O índice não recebe o corpo do artigo para analisá-lo no navegador. O servidor extrai os títulos das seções, e o client component recebe só eles:

tsx
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />

O corpo de um artigo longo fica no HTML, que é o lugar dele, e os dados enviados para a hidratação são alguns ids, contagens e textos de títulos.

Um filtro de tags feito com botões que alteram estado tem três problemas: não renderiza num Server Component, não faz nada até o JavaScript carregar, e a visualização filtrada não tem um endereço que você possa compartilhar ou que um buscador possa rastrear.

Um filtro de tags feito com links não tem nenhum deles:

tsx
<BlogTagFilter tags={tags} activeTagSlug={tag} href="/blog?tag={tagSlug}" allHref="/blog" showCounts />
<Pagination page={page} totalPages={totalPages} href={(n) => (n === 1 ? '/blog' : `/blog?page=${n}`)} />

A página renderiza de novo para a nova URL, o que um Server Component faz de qualquer jeito. A busca do blog segue a mesma regra: um formulário GET tem cinco linhas de HTML, então não lancei um componente para isso. A paginação acabou importando por mais do que conveniência, e expliquei o motivo num post separado sobre paginação de blog que os buscadores conseguem seguir.

Regra 5: comportamento em hooks, markup opcional

Todo componente interativo é uma camada fina de markup sobre um hook headless: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. O hook cuida do estado, das requisições, das atualizações otimistas e dos casos extremos. O componente é uma das formas de renderizá-lo.

Se o meu markup não combina com seu design, mantenha o comportamento e escreva o seu:

tsx
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });

O mesmo vale para a lógica dos Server Components. A extração de títulos, a janela de paginação e o agrupamento do roteiro são funções simples (extractHeadings, getPaginationItems, groupRoadmapPosts) que você chama a partir do seu próprio markup.

Regra 6: o contrato de estilos é API

Um kit de componentes só é útil na medida em que dá para personalizá-lo, e a personalização só é segura quando você sabe o que não vai mudar debaixo dos seus pés. Por isso tratei a superfície de estilos como API pública, com as mesmas garantias das props:

  • Variáveis CSS definem o tema: --kookee-primary, --kookee-radius, uma cor por tipo de entrada do registro de alterações.

  • Nomes de classe e slots estáveis. Todo elemento tem uma classe, e uma prop classNames chega a cada um: classNames={{ title: 'text-xl' }}.

  • Atributos data-* expõem o estado: data-active, data-pressed, data-vote, data-type. Estilize uma reação pressionada sem JavaScript.

  • Partes e funções de renderização. Todo layout padrão é montado com partes exportadas, e renderItem, renderLink e renderImage colocam as suas no lugar.

Nomes de classe, nomes de slot, atributos data-*, chaves de rótulos e variáveis só mudam numa versão major. O aninhamento dos elementos dentro de um componente não tem essa promessa, porque congelá-lo congelaria os componentes. Se seu CSS depende da estrutura, monte você mesmo as partes.

Duas decisões menores vêm da renderização no servidor. As datas são absolutas e calculadas em UTC, então o servidor e o navegador imprimem o mesmo texto e a hidratação nunca diverge. E a folha de estilos é opcional: sem ela, toda classe e todo atributo continuam lá, então uma equipe com design system próprio pode escrever todo o CSS.

Teste no próprio produto antes de publicar

Mais uma regra, sobre processo e não sobre código: antes de publicar, passei o portal hospedado do Kookee e o próprio kookee.dev para os componentes, área por área. Cada página tinha que bater com o design que já tinha, e onde não batia, a falha estava no componente, não na página.

Valeu a pena na hora. A migração revelou um índice que travava no último título, e a causa era o React 19 reescrevendo o dangerouslySetInnerHTML sempre que o componente pai renderiza de novo. Revelou também um parser de títulos que quebrava com uma entrada sem corpo. Nenhum dos dois apareceu no playground de componentes, e os dois teriam chegado aos clientes.

A versão curta

  • Receba os dados via props. Deixe o framework buscar.

  • Renderize no servidor por padrão; coloque as partes interativas num caminho /client.

  • Passe ids e contagens aos client components, não entradas inteiras.

  • Faça filtros e paginação como links.

  • Ponha o comportamento em hooks headless e a lógica em funções simples.

  • Trate nomes de classe, slots, atributos data-* e variáveis CSS como API.


É com essas regras que os novos componentes do @kookee/react são feitos: um blog, um registro de alterações com selo de novidades, uma central de ajuda com busca e voto de utilidade, e um roteiro somente leitura. Comece por Componentes React para seu blog, registro de alterações e central de ajuda ou veja como estilizar e personalizar os componentes.