Tôt ou tard, chaque CMS headless sort un kit React, et la plupart se ressemblent : enveloppez votre app dans un provider, ajoutez un <BlogList />, et le composant récupère lui-même ses articles. Ça fait une jolie démo. C’était une conception raisonnable quand toute app React était une application monopage.
Aujourd’hui, c’est la mauvaise conception. Avec les React Server Components, les loaders de routes de TanStack Start et de React Router, et le rendu serveur par défaut dans tout framework sérieux, un composant qui charge ses propres données se bat contre le framework dans lequel il tourne. Je viens de sortir des composants de blog, de journal des modifications, de centre d’aide et de feuille de route pour Kookee, et je les ai construits autour de la règle inverse. Voici le raisonnement, et les six règles qui en sont sorties.
Ce qui ne va pas quand un composant charge ses données
Prenons la forme classique :
<CmsProvider apiKey="…">
<BlogList limit={10} />
</CmsProvider>
BlogList appelle un hook, le hook charge les données dans un effet, et la liste s’affiche quand elles arrivent. Il en découle quatre choses, et aucune n’est bonne.
Le contenu n’est pas dans le HTML. Les effets ne s’exécutent pas sur le serveur. Le premier HTML que reçoit un visiteur ou un robot d’indexation affiche une icône de chargement à la place des articles. Les kits contournent le problème avec leur propre étape de préchargement, c’est-à-dire le chargement de données du framework réinventé dans un composant.
Tout devient un composant client. Un composant qui appelle des hooks doit tourner dans le navigateur, donc toute la liste, ses cartes et son balisage partent en JavaScript, pour un contenu qui ne change plus une fois affiché.
Les requêtes partent du mauvais endroit. Votre framework sait déjà charger des données : dans un Server Component, dans un loader, avec son propre cache et sa propre revalidation. Un composant qui charge ses données au montage contourne tout cela, et des composants imbriqués qui chargent chacun leurs données au montage le font l’un après l’autre, pas en parallèle.
Le contenu traverse le réseau deux fois. Dans l’App Router de Next.js, chaque prop d’un composant client est sérialisée dans la page pour que React puisse l’hydrater. Passez un article de blog entier à un composant client, et le corps de l’article arrive une fois en HTML et une seconde fois dans les données de la page.
Règle 1 : recevoir les données en props
Un composant devrait être une fonction de ses données. Il ne devrait pas savoir d’où elles viennent.
// app/blog/page.tsx, un Server Component
const { data } = await kookee.blog.list({ limit: 12 });
return <BlogPostList posts={data} href="/blog/{slug}" />;
Le chargement se fait là où votre framework le veut : dans le Server Component, dans le loader ou, dans une application monopage, dans un hook dont vous passez directement les data. Le même composant sert dans les trois cas. Il n’a besoin d’aucun provider, n’a pas d’état de chargement propre, et le cache est celui que votre framework gère déjà.
Cela rend aussi le composant très simple à tester. Donnez-lui un tableau, regardez le balisage.
Règle 2 : le serveur par défaut, le client seulement là où il faut le navigateur
L’essentiel d’une page de contenu est statique : listes, cartes, articles, fils d’Ariane, en-têtes. Très peu de choses ont besoin du navigateur. Sur un blog, ce sont les boutons de réaction et une table des matières qui met en évidence la section en cours de lecture. Sur un centre d’aide, un champ de recherche et un vote « Cet article vous a-t-il aidé ? ».
Chaque partie qui contient des éléments interactifs a donc deux chemins d’import :
@kookee/react/helpest rendu sur le serveur, sans état ni API du navigateur ;@kookee/react/help/client, comme les autres chemins/client, contient les éléments interactifs, marqués'use client'.
Avec les Server Components, une page rend presque tout sur le serveur et n’hydrate que les quelques îlots qui en ont besoin.
La séparation est imposée par le build, pas seulement documentée. Une vérification tourne sur les fichiers que je publie et échoue si un point d’entrée serveur crée un contexte React, est marqué 'use client' ou importe un hook que React n’exporte pas vers les Server Components. React n’y exporte que quelques hooks, useMemo, useCallback, useId et useDebugValue, et ce sont les quatre seuls que la vérification laisse passer. La même vérification fait échouer tout point d’entrée de composant qui importe autre chose que React et le SDK, ou qui dépasse 10 Ko après gzip.
Règle 3 : les composants client reçoivent des primitives
Les éléments interactifs sont des composants client, donc leurs props sont sérialisées dans la page. Ils reçoivent la plus petite donnée qui fait l’affaire.
Les boutons de réaction ne reçoivent pas un article. Ils reçoivent son identifiant et ses compteurs :
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />
La table des matières ne reçoit pas le corps de l’article pour l’analyser dans le navigateur. Le serveur extrait les titres, et le composant client ne reçoit qu’eux :
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />
Le corps d’un long article reste dans le HTML, là où il doit être, et les données envoyées pour l’hydratation se résument à quelques identifiants, compteurs et titres.
Règle 4 : filtres et pagination sont des liens
Un filtre par tag fait de boutons qui modifient un état pose trois problèmes : il ne peut pas s’afficher dans un Server Component, il ne fait rien tant que JavaScript n’est pas chargé, et une vue filtrée n’a pas d’adresse que l’on puisse partager ou qu’un moteur de recherche puisse explorer.
Un filtre par tag fait de liens n’en a aucun :
<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}`)} />
La page refait son rendu pour la nouvelle URL, ce que fait de toute façon un Server Component. La recherche du blog suit la même règle : un formulaire GET tient en cinq lignes de HTML, donc je n’ai pas livré de composant pour elle. La pagination s’est révélée importante au-delà du confort, et j’ai expliqué pourquoi dans un article séparé sur une pagination de blog que les moteurs de recherche peuvent suivre.
Règle 5 : le comportement dans des hooks, le balisage en option
Chaque composant interactif est une fine couche de balisage au-dessus d’un hook headless : useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. Le hook gère l’état, les requêtes, les mises à jour optimistes et les cas limites. Le composant n’est qu’une façon de l’afficher.
Si mon balisage ne convient pas à votre design, gardez le comportement et écrivez le vôtre :
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });
Il en va de même pour la logique des composants serveur. L’extraction des titres, la fenêtre de pagination et le regroupement de la feuille de route sont de simples fonctions (extractHeadings, getPaginationItems, groupRoadmapPosts) que vous pouvez appeler depuis votre propre balisage.
Règle 6 : le contrat de style fait partie de l’API
Un kit de composants n’est utile que s’il est personnalisable, et la personnalisation n’est sûre que si vous savez ce qui ne changera pas sous vos pieds. J’ai donc traité la surface de style comme une API publique, avec les mêmes garanties que les props :
Les variables CSS définissent le thème :
--kookee-primary,--kookee-radius, une couleur par type d’entrée du journal des modifications.Des noms de classes et des slots stables. Chaque élément a une classe, et une prop
classNamesatteint chacun d’eux :classNames={{ title: 'text-xl' }}.Les attributs
data-*exposent l’état :data-active,data-pressed,data-vote,data-type. Stylisez un bouton de réaction enfoncé sans JavaScript.Des parties et des fonctions de rendu. Chaque mise en page par défaut est construite à partir de parties exportées, et
renderItem,renderLinketrenderImageles remplacent par les vôtres.
Noms de classes, noms de slots, attributs data-*, clés de libellés et variables ne changent que dans une version majeure. L’imbrication des éléments dans un composant ne porte pas cette promesse, car la figer reviendrait à figer les composants. Si votre CSS dépend de la structure, assemblez vous-même les parties.
Deux décisions plus modestes découlent du rendu serveur. Les dates sont absolues et calculées en UTC : le serveur et le navigateur affichent la même chaîne, et l’hydratation ne diverge jamais. Et la feuille de style est facultative : sans elle, chaque classe et chaque attribut restent là, donc une équipe qui a son propre design system peut écrire tout le CSS.
Les utiliser soi-même avant de les publier
Une dernière règle, qui porte sur la méthode plutôt que sur le code : avant de publier, j’ai migré le portail hébergé de Kookee et kookee.dev lui-même vers les composants, partie par partie. Chaque page devait correspondre à son design existant, et là où ce n’était pas possible, le manque venait du composant, pas de la page.
Ça a payé tout de suite. La migration a révélé une table des matières bloquée sur le dernier titre, et la cause était React 19, qui réécrit dangerouslySetInnerHTML à chaque nouveau rendu de son parent. Elle a aussi révélé un parseur de titres qui plantait sur une entrée sans corps. Aucun des deux problèmes n’apparaissait dans le playground des composants, et les deux seraient partis chez les clients.
En bref
Recevez les données en props. Laissez le framework les charger.
Faites le rendu sur le serveur par défaut ; placez les parties interactives derrière un chemin
/client.Donnez aux composants client des identifiants et des compteurs, pas des entrées entières.
Faites des filtres et de la pagination des liens.
Mettez le comportement dans des hooks headless et la logique dans de simples fonctions.
Traitez les noms de classes, les slots, les attributs
data-*et les variables CSS comme une API.
Les nouveaux composants de @kookee/react sont construits selon ces règles : un blog, un journal des modifications avec un badge des nouveautés, un centre d’aide avec recherche et vote d’utilité, et une feuille de route en lecture seule. Commencez par Composants React pour votre blog, votre journal des modifications et votre centre d’aide, ou voyez comment les styliser et les personnaliser.