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:
<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.
// 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/helprenderiza 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:
<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:
<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.
Regra 4: filtros e paginação são links
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:
<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:
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
classNameschega 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,renderLinkerenderImagecolocam 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.