Sooner or later every headless CMS ships a React kit, and most of them look the same: wrap your app in a provider, drop in a <BlogList />, and the component fetches its own posts. It is a pleasant demo. It was a reasonable design when every React app was a single-page app.
It is the wrong design now. With React Server Components, route loaders in TanStack Start and React Router, and server rendering as the default in every serious framework, a component that fetches its own data fights the framework it runs in. We just shipped blog, changelog, help center and roadmap components for Kookee, and we built them around the opposite rule. Here is the reasoning, and the six rules that came out of it.
What goes wrong when a component fetches
Take the classic shape:
<CmsProvider apiKey="β¦">
<BlogList limit={10} />
</CmsProvider>
BlogList calls a hook, the hook fetches in an effect, and the list renders when the data arrives. Four things follow from that, none of them good.
The content is not in the HTML. Effects do not run on the server. The first HTML a visitor or a crawler receives has a spinner where the posts should be. Kits work around it with a prefetch step of their own, which is the framework's data loading reinvented inside a component.
Everything becomes a client component. A component that calls hooks has to run in the browser, so the whole list, its cards and its markup ship as JavaScript, for content that never changes after it renders.
Requests happen in the wrong place. Your framework already knows how to fetch: in a Server Component, in a loader, with its own cache and its own revalidation. A component that fetches on mount goes around all of that, and nested components that each fetch on mount load one after another instead of in parallel.
The content crosses the network twice. In the Next.js App Router, every prop of a client component is serialized into the page so React can hydrate it. Hand a client component a whole blog post and the article body arrives once as HTML and once again inside the page's data.
Rule 1: take data as props
A component should be a function of data. It should not know where the data came from.
// app/blog/page.tsx, a Server Component
const { data } = await kookee.blog.list({ limit: 12 });
return <BlogPostList posts={data} href="/blog/{slug}" />;
The fetch happens where your framework wants it: in the Server Component, in the loader, or, in a single-page app, in a hook whose data you pass straight in. The same component serves all three. It needs no provider, it has no loading state of its own, and caching is whatever your framework already does.
It also makes the component trivially testable. Give it an array, look at the markup.
Rule 2: server by default, client only where the browser is needed
Most of a content page is static: lists, cards, articles, breadcrumbs, headers. Very little of it needs the browser. On a blog, that is the reaction buttons and a table of contents that highlights the section being read. On a help center, a search box and a "Was this article helpful?" vote.
So every area with interactive pieces has two import paths:
@kookee/react/helprenders on the server, with no state and no browser APIs;@kookee/react/help/client, like the other/clientpaths, holds the interactive pieces, marked'use client'.
With Server Components, a page renders nearly everything on the server and hydrates only the few islands that need it.
The split is enforced by the build, not just documented. A check runs against the files we publish and fails if a server entry creates a React context, is marked 'use client', or imports a hook that React does not export to Server Components. React exports only a few hooks there, useMemo, useCallback, useId and useDebugValue, and those four are the only hooks the check lets through. The same check fails any component entry that imports something other than React and the SDK, or grows past 10 kB gzipped.
Rule 3: client components take primitives
The interactive pieces are client components, so their props are serialized into the page. They take the smallest thing that does the job.
The reaction buttons do not take a post. They take the post's id and its counts:
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />
The table of contents does not take the article body and parse it in the browser. The server extracts the headings, and the client component receives only those:
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />
A long article's body stays in the HTML, where it belongs, and the data sent for hydration is a few ids, counts and heading titles.
Rule 4: filters and pagination are links
A tag filter built as buttons that set state has three problems: it cannot render in a Server Component, it does nothing until JavaScript loads, and a filtered view has no address you can share or a search engine can crawl.
A tag filter built as links has none of them:
<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}`)} />
The page renders again for the new URL, which a Server Component does anyway. Blog search follows the same rule: a GET form is five lines of HTML, so we did not ship a component for it. Pagination turned out to matter for more than convenience, and we wrote up why in a separate post on blog pagination that search engines can follow.
Rule 5: behaviour in hooks, markup optional
Every interactive component is a thin layer of markup over a headless hook: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. The hook owns the state, the requests, the optimistic updates and the edge cases. The component is one way to render it.
If our markup does not fit your design, keep the behaviour and write your own:
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });
The same goes for the logic in the server components. Heading extraction, the pagination window and the roadmap's grouping are plain functions (extractHeadings, getPaginationItems, groupRoadmapPosts) you can call from your own markup.
Rule 6: the styling contract is API
A component kit is only as useful as it is customizable, and customization is only safe when you know what will not change under you. So we treated the styling surface as public API with the same guarantees as props:
CSS variables set the theme:
--kookee-primary,--kookee-radius, one colour per changelog type.Stable class names and slots. Every element has a class, and a
classNamesprop reaches each one:classNames={{ title: 'text-xl' }}.data-*attributes expose state:data-active,data-pressed,data-vote,data-type. Style a pressed reaction without JavaScript.Parts and render functions. Every default layout is built from exported parts, and
renderItem,renderLinkandrenderImageswap in your own.
Class names, slot names, data-* attributes, label keys and variables change only in a major release. The nesting of elements inside a component does not carry that promise, because freezing it would freeze the components. If your CSS depends on structure, compose the parts.
Two smaller decisions fall out of server rendering. Dates are absolute and computed in UTC, so the server and the browser print the same string and hydration never mismatches. And the stylesheet is optional: without it every class and attribute is still there, so a team with its own design system can write all of the CSS.
Dog-food it before you publish it
One more rule, about process rather than code: before publishing, we moved Kookee's hosted portal and kookee.dev itself onto the components, area by area. Every page had to match its existing design, and where it could not, that was a gap in the component, not in the page.
It paid off immediately. The migration turned up a table of contents that got stuck on the last heading, which turned out to be React 19 rewriting dangerouslySetInnerHTML whenever its parent re-renders. It turned up a heading parser that crashed on an entry with no body. Neither showed up in the component playground, and both would have shipped to customers.
The short version
Take data as props. Let the framework fetch.
Render on the server by default; put the interactive parts behind a
/clientpath.Give client components ids and counts, not whole entries.
Make filters and pagination links.
Put behaviour in headless hooks and logic in plain functions.
Treat class names, slots,
data-*attributes and CSS variables as API.
These rules are how the new components in @kookee/react are built: a blog, a changelog with a what's-new badge, a help center with search and a helpful vote, and a read-only roadmap. Start with React components for your blog, changelog and help center, or see how to style and customize them.