React Components

Using the React components with Next.js, TanStack Start or Vite

Updated 39 views

The components take their data as props, so they fit whatever your framework already does to load data. They are tested with React 19 in three setups: the Next.js App Router with Server Components, TanStack Start with server rendering and hydration, and a Vite single-page app. They also server-render and hydrate with React 18. Other frameworks with route loaders follow the TanStack Start pattern.

To start from a finished site instead, each setup below has a starter built from these components, a template repository with a live demo: Next.js, TanStack Start and Vite React, plus Astro for a static site.

Next.js App Router

Create one SDK client, and keep its config next to it for the client components:

TypeScript
// lib/kookee.ts
import { Kookee, type KookeeConfig } from '@kookee/sdk';

export const kookeeConfig: KookeeConfig = { apiKey: process.env.KOOKEE_API_KEY! };
export const kookee = new Kookee(kookeeConfig);

The key is read on the server, but the client components receive kookeeConfig as a prop, so it reaches the browser. That is what the API key is for: it is public, and the project's Allowed Origins decide which sites may use it. If the project's hosted portal is enabled, { projectId } works in its place.

Fetch in the Server Component and pass the result down. Nothing here runs in the browser:

tsx
// app/blog/page.tsx
import { BlogPostList } from '@kookee/react/blog';
import { kookee } from '@/lib/kookee';

export default async function BlogPage() {
  const { data } = await kookee.blog.list({ limit: 12 });
  return <BlogPostList posts={data} href="/blog/{slug}" tagHref="/blog?tag={tagSlug}" />;
}

A detail page adds the interactive pieces from the /client paths:

tsx
// app/blog/[slug]/page.tsx
import { EntryContent, extractHeadings } from '@kookee/react/entry';
import { Reactions, TrackedTableOfContents } from '@kookee/react/entry/client';
import { kookee, kookeeConfig } from '@/lib/kookee';

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const post = await kookee.blog.getBySlug((await params).slug);

  return (
    <>
      <TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />
      <EntryContent entry={post} className="prose" />
      <Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />
    </>
  );
}

Two rules come from how Next.js hands props to client components.

Client components get only what they need. Next.js serializes every prop of a client component into the page. That is why Reactions and HelpfulVote take an entry id and its counts, and TrackedTableOfContents takes the headings from extractHeadings, never a whole entry: a long article would otherwise cross the network twice.

A Server Component cannot pass functions to client components. The server-rendered components accept functions from a Server Component (href functions, renderLink, renderItem, renderImage, formatDate), because they render on the server too. The client components (Reactions, HelpfulVote, TrackedTableOfContents, WhatsNew and HelpSearchBox) cannot receive one, and Next.js stops with Functions cannot be passed directly to Client Components. Give them string href templates, or render them from a 'use client' file of your own when you need a function prop such as onReact, onResultClick or renderLink, and pass the config in from the server:

tsx
// app/help/search-box.tsx
'use client';

import Link from 'next/link';
import type { KookeeConfig } from '@kookee/sdk';
import { HelpSearchBox } from '@kookee/react/help/client';

export function SearchBox({ config }: { config: KookeeConfig }) {
  return (
    <HelpSearchBox
      config={config}
      href="/help/{categorySlug}/{slug}"
      renderLink={(props) => <Link {...props} />}
      onResultClick={(result) => trackEvent('help_search_click', { id: result.id })}
    />
  );
}

trackEvent stands for your analytics call. Render it from the page as <SearchBox config={kookeeConfig} />: a client file cannot read process.env.KOOKEE_API_KEY itself, because Next.js only exposes NEXT_PUBLIC_ variables to the browser.

Images

renderImage swaps the plain <img> for next/image:

tsx
import Image from 'next/image';

<BlogPostList
  posts={data}
  href="/blog/{slug}"
  renderImage={({ src, alt, className, priority }) => (
    <Image src={src} alt={alt} className={className} priority={priority} fill sizes="(min-width: 768px) 33vw, 100vw" />
  )}
/>;

The cover box is positioned with a fixed aspect ratio, so fill fits it. Add the Kookee media host to next.config:

TypeScript
images: {
  remotePatterns: [{ protocol: 'https', hostname: 'cdn.kookee.dev' }],
},

TanStack Start

Route loaders run on the server for the first request and in the browser on client-side navigation, so the client must work in both. Read the key from import.meta.env, which Vite fills in for both, rather than process.env:

TypeScript
// src/lib/kookee.ts
import { Kookee, type KookeeConfig } from '@kookee/sdk';

export const kookeeConfig: KookeeConfig = { apiKey: import.meta.env.VITE_KOOKEE_API_KEY };
export const kookee = new Kookee(kookeeConfig);

TanStack Router needs one adapter for the components' links. Its to prop is a pathname, so a query string inside it is dropped: a pagination link to /blog?page=2 would lead to /blog. Split the href into path, search and hash before handing it over. Match active links exactly too; by default TanStack marks every link whose path and search the current URL contains as aria-current="page", and "All" would look selected next to the filter the visitor picked:

tsx
// src/lib/router-link.tsx
import { Link, useRouter } from '@tanstack/react-router';
import type { RenderLinkProps } from '@kookee/react/entry';

export function RouterLink({ href, ...props }: RenderLinkProps) {
  const router = useRouter();
  const url = new URL(href, 'http://local.invalid');

  return (
    <Link
      {...props}
      to={url.pathname}
      search={router.options.parseSearch(url.search)}
      hash={url.hash.slice(1) || undefined}
      activeOptions={{ exact: true }}
    />
  );
}

parseSearch is the router's own parser, so ?page=2 arrives as the number your route's search schema expects. The adapter is for links within your site: leave renderLink off a component whose links go elsewhere, such as roadmap cards that point at your hosted portal.

Then fetch in the loader and read the result with useLoaderData():

tsx
// src/routes/changelog/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { ChangelogList } from '@kookee/react/changelog';
import { kookee } from '~/lib/kookee';
import { RouterLink } from '~/lib/router-link';

export const Route = createFileRoute('/changelog/')({
  loader: () => kookee.changelog.list({ limit: 20 }),
  component: function Changelog() {
    const { data } = Route.useLoaderData();
    return <ChangelogList entries={data} href="/changelog/{slug}" renderLink={(props) => <RouterLink {...props} />} />;
  },
});

A single-page app

Without server rendering, fetch with the hooks and hand their data to the components. The hooks need a KookeeProvider at the root, as described in Reading Kookee content with React hooks:

tsx
import { useBlogPosts } from '@kookee/react';
import { BlogPostList, BlogPostListSkeleton } from '@kookee/react/blog';
import { Link } from 'react-router';

export function Blog() {
  const { data, isLoading, hasMore, loadMore } = useBlogPosts({ limit: 12 });

  if (isLoading) return <BlogPostListSkeleton count={6} />;

  return (
    <>
      <BlogPostList posts={data} href="/blog/{slug}" renderLink={({ href, ...props }) => <Link to={href} {...props} />} />
      {hasMore && <button onClick={loadMore}>Load more</button>}
    </>
  );
}

The client components work the same way here. They do not read the provider: Reactions, HelpfulVote and HelpSearchBox take config directly.

Pass getPath in the provider's config as well, and the links between entries inside the bodies the hooks return get their href:

tsx
<KookeeProvider config={{ apiKey: import.meta.env.VITE_KOOKEE_API_KEY, getPath }}>

getPath takes an entry (its type, id, slug, locale and category) and returns its path on your site, or null when the entry has no page there. Without it those links have no href and read as text. An inline function is fine: the provider keeps the latest one without rebuilding its client, so the hooks do not refetch. It needs @kookee/react 1.17.0 with @kookee/sdk 1.17.0.

A status page

The status components in @kookee/react/status render on the server like the other areas, so a Server Component fetches with kookee.status.get() and passes the result in, with no client file:

tsx
// app/status/page.tsx
import { StatusHeadline, StatusOverview } from '@kookee/react/status';
import { kookee } from '@/lib/kookee';

export default async function StatusPage() {
  const status = await kookee.status.get();

  return (
    <>
      <StatusHeadline status={status} />
      <StatusOverview status={status} incidentHref="/status/incidents/{id}" />
    </>
  );
}

An incident's page passes kookee.status.getIncident(id) to StatusIncidentArticle. Visitors read a status page during an incident, so cache the read on a server for about a minute, not hours, and revalidate it on the incident.created, incident.updated, monitor.down and monitor.up webhooks. In the browser, useStatus({ refreshSeconds: 60 }) from the package root reads it and refetches it every minute while the tab is visible; the TanStack Start and Vite React starters do it that way. The components need @kookee/react 1.19.0 with @kookee/sdk 1.19.0. Show your status page on your own site covers the rest.

Dates and hydration

Dates render as absolute dates in UTC, such as Sep 15, 2026, so the server's HTML and the browser's first render agree and React reports no hydration mismatch. locale changes the format. For relative dates such as "2 days ago", pass formatDate and compute them after hydration: the server's clock and the visitor's are not the same, and neither is their time zone.

The status components show times of day too, with their zone, in UTC unless you pass timeZone. Pass the visitor's zone, Intl.DateTimeFormat().resolvedOptions().timeZone, only where they render in the browser alone, such as after useStatus has loaded, so the server's HTML and the browser's first render still agree.

Was this article helpful?