Los componentes React de tu CMS no deberían obtener sus propios datos

headless-cms

Tarde o temprano, todo CMS headless publica un kit para React, y la mayoría se parecen: envuelve tu app en un provider, coloca un <BlogList /> y el componente obtiene sus propias entradas. Queda bien en una demo. Era un diseño razonable cuando toda app de React era una aplicación de una sola página.

Hoy es el diseño equivocado. Con React Server Components, los loaders de rutas de TanStack Start y React Router, y el renderizado en el servidor por defecto en todo framework serio, un componente que obtiene sus propios datos choca con el framework en el que corre. Acabo de publicar componentes de blog, registro de cambios, centro de ayuda y hoja de ruta para Kookee, y los construí en torno a la regla opuesta. Este es el razonamiento, y las seis reglas que salieron de él.

Qué sale mal cuando un componente obtiene sus datos

Toma la forma clásica:

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

BlogList llama a un hook, el hook hace la petición en un efecto, y la lista se renderiza cuando llegan los datos. De ahí salen cuatro consecuencias, ninguna buena.

El contenido no está en el HTML. Los efectos no se ejecutan en el servidor. El primer HTML que recibe un visitante o un rastreador tiene un indicador de carga donde deberían estar las entradas. Los kits lo esquivan con un paso de precarga propio, que es la carga de datos del framework reinventada dentro de un componente.

Todo se vuelve un componente de cliente. Un componente que llama a hooks tiene que ejecutarse en el navegador, así que toda la lista, sus tarjetas y su marcado viajan como JavaScript, para un contenido que no cambia después de renderizarse.

Las peticiones ocurren en el lugar equivocado. Tu framework ya sabe obtener datos: en un Server Component, en un loader, con su propia caché y su propia revalidación. Un componente que hace la petición al montarse se salta todo eso, y los componentes anidados que hacen cada uno su petición al montarse cargan uno tras otro en lugar de en paralelo.

El contenido cruza la red dos veces. En el App Router de Next.js, cada prop de un componente de cliente se serializa en la página para que React pueda hidratarlo. Pásale a un componente de cliente una entrada de blog completa, y el cuerpo del artículo llega una vez como HTML y otra vez dentro de los datos de la página.

Regla 1: recibe los datos como props

Un componente debería ser una función de los datos. No debería saber de dónde vienen.

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

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

La petición ocurre donde tu framework la quiere: en el Server Component, en el loader o, en una aplicación de una sola página, en un hook cuyo data pasas directamente. El mismo componente sirve para los tres casos. No necesita provider, no tiene estado de carga propio, y la caché es la que tu framework ya tenga.

Además, el componente se vuelve trivial de probar. Dale un array y mira el marcado.

Regla 2: servidor por defecto, cliente solo donde hace falta el navegador

La mayor parte de una página de contenido es estática: listas, tarjetas, artículos, migas de pan, cabeceras. Muy poco necesita el navegador. En un blog, son los botones de reacción y un índice que resalta la sección que se está leyendo. En un centro de ayuda, un cuadro de búsqueda y una votación «¿Te resultó útil este artículo?».

Así que cada área con piezas interactivas tiene dos rutas de importación:

  • @kookee/react/help se renderiza en el servidor, sin estado y sin APIs del navegador;

  • @kookee/react/help/client, como las demás rutas /client, contiene las piezas interactivas, marcadas con 'use client'.

Con Server Components, una página renderiza casi todo en el servidor e hidrata solo las pocas islas que lo necesitan.

La separación la impone el build, no solo la documentación. Una comprobación se ejecuta sobre los archivos que publico y falla si un punto de entrada de servidor crea un contexto de React, está marcado con 'use client' o importa un hook que React no exporta a Server Components. React solo exporta unos pocos hooks ahí, useMemo, useCallback, useId y useDebugValue, y esos cuatro son los únicos que la comprobación deja pasar. La misma comprobación hace fallar cualquier punto de entrada de componentes que importe algo distinto de React y el SDK, o que supere los 10 kB comprimidos con gzip.

Regla 3: los componentes de cliente reciben primitivos

Las piezas interactivas son componentes de cliente, así que sus props se serializan en la página. Reciben lo mínimo que necesitan.

Los botones de reacción no reciben la entrada entera. Reciben su id y sus contadores:

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

El índice no recibe el cuerpo del artículo para analizarlo en el navegador. El servidor extrae los encabezados, y el componente de cliente recibe solo eso:

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

El cuerpo de un artículo largo se queda en el HTML, donde debe estar, y los datos que se envían para la hidratación son unos pocos ids, contadores y títulos de encabezados.

Regla 4: los filtros y la paginación son enlaces

Un filtro de etiquetas hecho con botones que cambian el estado tiene tres problemas: no puede renderizarse en un Server Component, no hace nada hasta que carga JavaScript, y una vista filtrada no tiene una dirección que puedas compartir ni que un motor de búsqueda pueda rastrear.

Un filtro de etiquetas hecho con enlaces no tiene ninguno:

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}`)} />

La página se vuelve a renderizar para la nueva URL, cosa que un Server Component hace de todos modos. La búsqueda del blog sigue la misma regla: un formulario GET son cinco líneas de HTML, así que no publiqué un componente para eso. La paginación resultó importar por algo más que la comodidad, y expliqué por qué en otra entrada sobre una paginación de blog que los motores de búsqueda puedan seguir.

Regla 5: el comportamiento en hooks, el marcado opcional

Cada componente interactivo es una capa fina de marcado sobre un hook headless: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. El hook se encarga del estado, las peticiones, las actualizaciones optimistas y los casos límite. El componente es una forma de renderizarlo.

Si mi marcado no encaja con tu diseño, quédate con el comportamiento y escribe el tuyo:

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

Lo mismo vale para la lógica de los componentes de servidor. La extracción de encabezados, la ventana de paginación y la agrupación de la hoja de ruta son funciones simples (extractHeadings, getPaginationItems, groupRoadmapPosts) que puedes llamar desde tu propio marcado.

Regla 6: el contrato de estilos es API

Un kit de componentes es tan útil como personalizable, y personalizarlo solo es seguro cuando sabes qué no va a cambiar bajo tus pies. Así que traté la superficie de estilos como API pública, con las mismas garantías que las props:

  • Variables CSS para el tema: --kookee-primary, --kookee-radius, un color por tipo de entrada del registro de cambios.

  • Nombres de clase y slots estables. Cada elemento tiene una clase, y la prop classNames llega a cada uno: classNames={{ title: 'text-xl' }}.

  • Atributos data-* que exponen el estado: data-active, data-pressed, data-vote, data-type. Da estilo a una reacción presionada sin JavaScript.

  • Partes y funciones de renderizado. Cada diseño predeterminado está hecho con partes exportadas, y renderItem, renderLink y renderImage las cambian por las tuyas.

Los nombres de clase, los nombres de slot, los atributos data-*, las claves de los textos y las variables solo cambian en una versión mayor. El anidamiento de elementos dentro de un componente no tiene esa garantía, porque congelarlo congelaría los componentes. Si tu CSS depende de la estructura, compón las partes.

De renderizar en el servidor salen otras dos decisiones menores. Las fechas son absolutas y se calculan en UTC, así que el servidor y el navegador imprimen la misma cadena y la hidratación nunca encuentra diferencias. Y la hoja de estilos es opcional: sin ella, todas las clases y atributos siguen ahí, así que un equipo con su propio sistema de diseño puede escribir todo el CSS.

Úsalo en tu propio producto antes de publicarlo

Una regla más, sobre el proceso y no sobre el código: antes de publicar, pasé el portal alojado de Kookee y el propio kookee.dev a los componentes, área por área. Cada página tenía que coincidir con su diseño existente, y cuando no podía, era una carencia del componente, no de la página.

Dio resultado enseguida. La migración destapó un índice que se quedaba atascado en el último encabezado, y la causa resultó ser que React 19 reescribe dangerouslySetInnerHTML cada vez que su componente padre se vuelve a renderizar. También destapó un analizador de encabezados que fallaba con una entrada sin cuerpo. Ninguno de los dos apareció en el playground de componentes, y los dos habrían llegado a los clientes.

En resumen

  • Recibe los datos como props. Deja que el framework los obtenga.

  • Renderiza en el servidor por defecto; pon las partes interactivas detrás de una ruta /client.

  • Dale a los componentes de cliente ids y contadores, no entradas completas.

  • Haz que los filtros y la paginación sean enlaces.

  • Pon el comportamiento en hooks headless y la lógica en funciones simples.

  • Trata los nombres de clase, los slots, los atributos data-* y las variables CSS como API.


Estas reglas son la base de los nuevos componentes de @kookee/react: un blog, un registro de cambios con una insignia de novedades, un centro de ayuda con búsqueda y votación de utilidad, y una hoja de ruta de solo lectura. Empieza por Componentes React para tu blog, registro de cambios y centro de ayuda, o mira cómo darles estilo y personalizarlos.