Früher oder später bringt jedes Headless-CMS ein React-Kit heraus, und die meisten sehen gleich aus: die App in einen Provider packen, ein <BlogList /> einsetzen, und die Komponente lädt ihre Beiträge selbst. Als Demo ist das angenehm. Solange jede React-App eine Single-Page-App war, war es auch ein vernünftiges Design.
Heute ist es das falsche. Mit React Server Components, Route-Loadern in TanStack Start und React Router und Server-Rendering als Standard in jedem ernstzunehmenden Framework kämpft eine Komponente, die ihre Daten selbst lädt, gegen das Framework, in dem sie läuft. Ich habe gerade Komponenten für Blog, Changelog, Hilfecenter und Roadmap für Kookee veröffentlicht und sie nach der umgekehrten Regel gebaut. Hier die Begründung und die sechs Regeln, die daraus entstanden sind.
Was schiefgeht, wenn eine Komponente selbst lädt
Nimm das klassische Muster:
<CmsProvider apiKey="…">
<BlogList limit={10} />
</CmsProvider>
BlogList ruft einen Hook auf, der Hook lädt in einem Effect, und die Liste rendert, wenn die Daten ankommen. Daraus folgen vier Dinge, und keins davon ist gut.
Der Inhalt steht nicht im HTML. Effects laufen nicht auf dem Server. Das erste HTML, das ein Besucher oder ein Crawler bekommt, zeigt einen Ladekreisel, wo die Beiträge stehen sollten. Kits umgehen das mit einem eigenen Prefetch-Schritt, und der ist nichts anderes als das Data Loading des Frameworks, in einer Komponente neu erfunden.
Alles wird zur Client Component. Eine Komponente, die Hooks aufruft, muss im Browser laufen. Also werden die ganze Liste, ihre Karten und ihr Markup als JavaScript ausgeliefert, für Inhalte, die sich nach dem Rendern nie ändern.
Anfragen passieren an der falschen Stelle. Dein Framework weiß schon, wie man Daten lädt: in einer Server Component, in einem Loader, mit eigenem Cache und eigener Revalidierung. Eine Komponente, die beim Mounten lädt, umgeht all das, und verschachtelte Komponenten, die jeweils beim Mounten laden, laden nacheinander statt parallel.
Der Inhalt geht zweimal über das Netzwerk. Im App Router von Next.js wird jede Prop einer Client Component in die Seite serialisiert, damit React sie hydrieren kann. Gib einer Client Component einen ganzen Blogbeitrag, und der Artikeltext kommt einmal als HTML und noch einmal in den Daten der Seite an.
Regel 1: Daten als Props entgegennehmen
Eine Komponente sollte eine Funktion ihrer Daten sein. Woher die Daten kommen, sollte sie nicht wissen.
// app/blog/page.tsx, eine Server Component
const { data } = await kookee.blog.list({ limit: 12 });
return <BlogPostList posts={data} href="/blog/{slug}" />;
Geladen wird dort, wo dein Framework es will: in der Server Component, im Loader oder, in einer Single-Page-App, in einem Hook, dessen data du direkt übergibst. Dieselbe Komponente funktioniert in allen drei Fällen. Sie braucht keinen Provider, hat keinen eigenen Ladezustand, und gecacht wird so, wie dein Framework es ohnehin tut.
Außerdem wird die Komponente so trivial testbar. Gib ihr ein Array und sieh dir das Markup an.
Regel 2: standardmäßig Server, Client nur, wo der Browser nötig ist
Der größte Teil einer Content-Seite ist statisch: Listen, Karten, Artikel, Breadcrumbs, Header. Nur wenig davon braucht den Browser. In einem Blog sind das die Reaktions-Buttons und ein Inhaltsverzeichnis, das den gerade gelesenen Abschnitt hervorhebt. Im Hilfecenter sind es ein Suchfeld und die Abstimmung „War dieser Artikel hilfreich?“.
Deshalb hat jeder Bereich mit interaktiven Teilen zwei Importpfade:
@kookee/react/helprendert auf dem Server, ohne State und ohne Browser-APIs;@kookee/react/help/cliententhält, wie die anderen/client-Pfade, die interaktiven Teile, markiert mit'use client'.
Mit Server Components rendert eine Seite fast alles auf dem Server und hydriert nur die wenigen Inseln, die es brauchen.
Diese Trennung erzwingt der Build, sie steht nicht nur in der Doku. Ein Check läuft über die Dateien, die ich veröffentliche, und schlägt fehl, wenn ein Server-Einstiegspunkt einen React-Context erzeugt, mit 'use client' markiert ist oder einen Hook importiert, den React für Server Components nicht exportiert. Dort exportiert React nur wenige Hooks, useMemo, useCallback, useId und useDebugValue, und nur diese vier lässt der Check durch. Derselbe Check schlägt bei jedem Komponenten-Einstiegspunkt fehl, der etwas anderes als React und das SDK importiert oder über 10 kB gzipped wächst.
Regel 3: Client Components bekommen Primitive
Die interaktiven Teile sind Client Components, also werden ihre Props in die Seite serialisiert. Sie bekommen das Kleinste, was für ihre Aufgabe reicht.
Die Reaktions-Buttons bekommen keinen Beitrag. Sie bekommen die ID des Beitrags und seine Zähler:
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />
Das Inhaltsverzeichnis bekommt nicht den Artikeltext, um ihn im Browser zu parsen. Der Server extrahiert die Überschriften, und die Client Component erhält nur diese:
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />
Der Text eines langen Artikels bleibt im HTML, wo er hingehört, und die Daten für die Hydrierung sind ein paar IDs, Zähler und Überschriften.
Regel 4: Filter und Paginierung sind Links
Ein Tag-Filter aus Buttons, die State setzen, hat drei Probleme: Er kann nicht in einer Server Component rendern, er tut nichts, bis JavaScript geladen ist, und eine gefilterte Ansicht hat keine Adresse, die du teilen oder eine Suchmaschine crawlen kann.
Ein Tag-Filter aus Links hat keins davon:
<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}`)} />
Die Seite rendert für die neue URL neu, was eine Server Component ohnehin tut. Die Blogsuche folgt derselben Regel: Ein GET-Formular sind fünf Zeilen HTML, deshalb gibt es dafür keine Komponente. Bei der Paginierung geht es um mehr als Bequemlichkeit, und warum, habe ich in einem eigenen Beitrag über Blog-Paginierung, der Suchmaschinen folgen können aufgeschrieben.
Regel 5: Verhalten in Hooks, Markup optional
Jede interaktive Komponente ist eine dünne Schicht Markup über einem Headless-Hook: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. Der Hook verwaltet den State, die Anfragen, die optimistischen Updates und die Randfälle. Die Komponente ist nur eine Art, ihn darzustellen.
Wenn mein Markup nicht zu deinem Design passt, behalte das Verhalten und schreib dein eigenes:
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });
Dasselbe gilt für die Logik in den Server Components. Das Extrahieren der Überschriften, das Fenster der Paginierung und die Gruppierung der Roadmap sind einfache Funktionen (extractHeadings, getPaginationItems, groupRoadmapPosts), die du aus deinem eigenen Markup aufrufen kannst.
Regel 6: Der Styling-Vertrag ist API
Ein Komponenten-Kit ist nur so nützlich, wie es anpassbar ist, und Anpassungen sind nur sicher, wenn du weißt, was sich nicht hinter deinem Rücken ändert. Deshalb behandle ich die Styling-Oberfläche als öffentliche API, mit denselben Garantien wie Props:
CSS-Variablen setzen das Theme:
--kookee-primary,--kookee-radius, eine Farbe pro Changelog-Typ.Stabile Klassennamen und Slots. Jedes Element hat eine Klasse, und die Prop
classNameserreicht jedes davon:classNames={{ title: 'text-xl' }}.data-*-Attribute zeigen den Zustand:data-active,data-pressed,data-vote,data-type. So gestaltest du eine gedrückte Reaktion ohne JavaScript.Teile und Render-Funktionen. Jedes Standardlayout ist aus exportierten Teilen gebaut, und
renderItem,renderLinkundrenderImagesetzen deine eigenen ein.
Klassennamen, Slot-Namen, data-*-Attribute, Label-Schlüssel und Variablen ändern sich nur in einem Major-Release. Für die Verschachtelung der Elemente in einer Komponente gilt dieses Versprechen nicht, denn sie einzufrieren hieße, die Komponenten einzufrieren. Wenn dein CSS von der Struktur abhängt, setz die Teile selbst zusammen.
Zwei kleinere Entscheidungen folgen aus dem Server-Rendering. Datumsangaben sind absolut und werden in UTC berechnet, damit Server und Browser denselben String ausgeben und die Hydrierung nie abweicht. Und das Stylesheet ist optional: Ohne es sind alle Klassen und Attribute trotzdem da, sodass ein Team mit eigenem Designsystem das gesamte CSS selbst schreiben kann.
Erst selbst nutzen, dann veröffentlichen
Noch eine Regel, eher zum Prozess als zum Code: Vor der Veröffentlichung habe ich das gehostete Portal von Kookee und kookee.dev selbst auf die Komponenten umgestellt, Bereich für Bereich. Jede Seite musste ihrem bisherigen Design entsprechen, und wo das nicht ging, war das eine Lücke in der Komponente, nicht in der Seite.
Das hat sich sofort ausgezahlt. Bei der Migration fiel ein Inhaltsverzeichnis auf, das an der letzten Überschrift hängen blieb. Die Ursache war React 19, das dangerouslySetInnerHTML bei jedem Re-Render des Elternelements neu schreibt. Außerdem tauchte ein Überschriften-Parser auf, der bei einem Eintrag ohne Text abstürzte. Keins von beiden ist im Komponenten-Playground aufgefallen, und beide wären bei Kunden gelandet.
Die Kurzfassung
Nimm Daten als Props entgegen. Lass das Framework laden.
Rendere standardmäßig auf dem Server; leg die interaktiven Teile hinter einen
/client-Pfad.Gib Client Components IDs und Zähler, keine ganzen Einträge.
Mach Filter und Paginierung zu Links.
Leg Verhalten in Headless-Hooks und Logik in einfache Funktionen.
Behandle Klassennamen, Slots,
data-*-Attribute und CSS-Variablen als API.
Nach diesen Regeln sind die neuen Komponenten in @kookee/react gebaut: ein Blog, ein Changelog mit Badge für Neuigkeiten, ein Hilfecenter mit Suche und Hilfreich-Abstimmung und eine schreibgeschützte Roadmap. Fang mit React-Komponenten für Blog, Changelog und Hilfecenter an, oder lies, wie du sie gestaltest und anpasst.