@kookee/react
v1.4.1
Published
Official Kookee React SDK - hooks, content rendering and an embeddable AI support chat
Maintainers
Readme
@kookee/react
Official React SDK for Kookee — hooks for your blog, changelog, help center and feedback board, plus an embeddable AI support chat.
Features
- Hooks for every module — blog, pages, help center, changelog, announcements, feedback and custom entry types
- Headless — hooks return data, you own the markup
- Server rendering —
initialDatahands framework-loader results to any hook - No query-layer dependency — the root export has no runtime dependencies
- Tree-shakeable — importing one hook costs ~2 kB, not the whole library
- Chat widget — opinionated, styled, on its own subpath so it costs nothing unless used
Installation
npm install @kookee/react @kookee/sdk@kookee/sdk 1.1.0 or later is a peer dependency, as are react and react-dom
(18 or 19).
Quick Start
Wrap your app once:
import { KookeeProvider } from '@kookee/react';
<KookeeProvider config={{ projectId: 'your-project-id' }} locale="en">
<App />
</KookeeProvider>;Then read data anywhere:
import { useBlogPosts } from '@kookee/react';
function Blog() {
const { data, isLoading, error, loadMore, hasMore } = useBlogPosts({ limit: 10 });
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<>
{data.map((post) => (
<article key={post.id}>{post.title}</article>
))}
{hasMore && <button onClick={loadMore}>Load more</button>}
</>
);
}projectId or a public apiKey
Use projectId in browser code where you can: it is safe to expose and grants read-only
access to published content. An apiKey in a client bundle ships to every visitor, so if
you use one in the browser (the script-tag widget below does), restrict it to your domains
with the project's allowed origins.
Provider
<KookeeProvider config={{ projectId, baseUrl }} locale="en">| Prop | Type | Description |
| --- | --- | --- |
| config | KookeeConfig | projectId (or apiKey), optional baseUrl |
| locale | string | Default locale for every hook; per-call params override it |
The client is created once and shared. useKookee() returns it directly — the escape hatch
for anything the hooks do not cover:
const kookee = useKookee();
await kookee.entries.react(postId, { reactionType: 'like', action: 'add' });Hooks
Every hook returns { data, isLoading, error, refetch }. List hooks add
{ loadMore, hasMore, isLoadingMore, total }.
| Module | Hooks |
| --- | --- |
| Blog | useBlogPosts · useBlogPost · useBlogPostById · useBlogTags |
| Pages | usePages · usePage · usePageById |
| Help | useHelpArticles · useHelpArticle · useHelpArticleById · useHelpCategories · useHelpSearch |
| Changelog | useChangelog · useChangelogEntry · useChangelogEntryById |
| Announcements | useAnnouncements · useAnnouncement |
| Feedback | useFeedbackPosts · useFeedbackPost · useFeedbackColumns · useFeedbackTopContributors · useFeedbackComments |
| Custom types | useEntries · useEntry · useEntryById · useEntryTags · useEntryCategories |
| Shared | useComments · useReact · useConfig · useConfigList · useTranslations · useTranslationsById |
Detail hooks stay idle until their argument resolves
Pass undefined while a route param is still loading and the hook makes no request:
const { slug } = useParams();
const { data: post, isLoading } = useBlogPost(slug); // idle while slug is undefinedCustom entry types
useEntries is how you read entry types you defined yourself. type is required:
const { data } = useEntries({ type: 'recipe', limit: 20 });
const { data: recipe } = useEntry(slug, { type: 'recipe' });Search
useHelpSearch debounces internally, so it is safe to wire straight to an input:
const [query, setQuery] = useState('');
const { data: results, isLoading } = useHelpSearch(query);Reactions
useReact is a mutation, not a query:
const { react, isPending } = useReact(post.id);
<button disabled={isPending} onClick={() => react({ reactionType: 'like', action: 'add' })}>
👍 {post.reactions.like ?? 0}
</button>;react() resolves with the updated reactions map, so you can use its return value
instead of refetching.
It does not update the entry in place — the hook holding that entry keeps its old counts
until you use the returned value or call its refetch().
Rendering content
import { EntryContent } from '@kookee/react';
<EntryContent entry={post} className="prose" />;EntryContent accepts detail entries only. List responses do not include
contentHtml, so passing a list item is a compile error rather than a blank page:
const { data: posts } = useBlogPosts();
<EntryContent entry={posts[0]} />; // ✗ Type error — list items have no body
const { data: post } = useBlogPost(slug);
<EntryContent entry={post} />; // ✓It renders the HTML your Kookee project produced, as-is, and has no DOM dependencies — the same markup on the server and the client. Content that originates outside Kookee should be sanitised before it reaches the CMS.
The container always carries the kookee-entry-content class (your className is merged
after it) — the class the SDK stylesheets are scoped to. For syntax-highlighted code
blocks and file attachment chips, add the SDK's content stylesheet:
import '@kookee/sdk/styles/content.css';In a Tailwind v4 app, import it into a cascade layer instead, so your prose-code:
utilities keep winning over the stylesheet's defaults (an unlayered import would beat any
layered utility regardless of specificity):
@import '@kookee/sdk/styles/content.css' layer(components);The flip side of the layered import: prose's own pre styles and prose-code:
utilities also reach elements inside code blocks, and the layered stylesheet can't
defend against them. If you render entry content inside prose containers, add this
unlayered guard once:
.kookee-entry-content .kookee-code-block pre {
margin: 0;
border-radius: 0;
}
.kookee-entry-content .kookee-code-block pre code {
background: none;
padding: 0;
border: none;
border-radius: 0;
font-size: 0.875rem;
}If your app has no typography system of its own (no Tailwind prose), also add
@kookee/sdk/styles/typography.css for baseline headings, lists, tables and images —
scoped to the same class, inheriting your page's font and colors.
The content stylesheet is themeable through --kookee-code-* and --kookee-file-chip-*
custom properties — set them on :root to match your design system (required for dark
mode, whose chip fallbacks are light-theme colors). See the
@kookee/sdk README for the full list.
Reading custom fields
Changelog type, version, link and any field you defined yourself live in entry.fields,
not as top-level properties. Read them with the SDK's helpers:
import { fieldOptionKey, fieldStringValue } from '@kookee/sdk';
const type = fieldOptionKey(entry.fields, 'changelogType'); // 'feature' | 'fix' | ...
const version = fieldStringValue(entry.fields, 'version'); // '2.1.0'Both return undefined when the field is absent, so entry types without them need no
special-casing.
Server rendering
Hooks fetch in an effect, which does not run on the server. Fetch in your framework's
loader and pass the result as initialData — the hook renders it immediately and skips its
first request:
// TanStack Start
export const Route = createFileRoute('/blog')({
loader: () => kookee.blog.list({ limit: 10 }),
component: Blog,
});
function Blog() {
const loaderData = Route.useLoaderData();
const { data, loadMore } = useBlogPosts({ limit: 10 }, { initialData: loaderData });
// Server-renders from loaderData; loadMore still works on the client.
}The same pattern applies to Next.js server components and any other loader. initialData
covers the parameters it was fetched with — changing them fetches normally.
Caching
There is none. Two components calling useBlogPosts() make two requests. This keeps the
package dependency-free and predictable. If you want caching, deduplication or background
refetching, use useKookee() with your own query layer:
const kookee = useKookee();
const { data } = useQuery({
queryKey: ['blog', 'list'],
queryFn: ({ signal }) => kookee.blog.list({ limit: 10 }, signal),
});Requests are cancelled when inputs change or a component unmounts, and out-of-order responses are discarded, so you will not see a slow response overwrite a newer one.
Errors
error is the SDK's KookeeApiError for API failures, carrying code and status:
import { KookeeApiError } from '@kookee/sdk';
if (error instanceof KookeeApiError && error.status === 404) return <NotFound />;Network and CORS failures reject as TypeError, so check the type before reading status.
Chat widget
The AI support chat lives on its own subpath, so its markdown renderer and syntax highlighter are only bundled if you use it:
import { KookeeChatWidget } from '@kookee/react/chat';
import '@kookee/react/chat.css';
<KookeeChatWidget clientOptions={{ projectId: 'your-project-id' }} locale="en" />;| Export | Description |
| --- | --- |
| KookeeChatWidget | Floating button and panel, ready to drop in |
| KookeeChat | The chat surface alone, for your own container |
| KookeeChatProvider / useKookeeChat | Bring your own UI |
The widget works standalone. Inside a KookeeProvider it reuses that client instead of
creating a second one.
<KookeeChatWidget
clientOptions={{ projectId }}
locale="en"
onSourceClick={(source) => navigate(`/help/${source.slug}`)}
/>onSourceClick receives clicks on both the source chips under an answer and the inline
article citations in the answer text. Without it, citations render as inert link-styled
text.
The conversation is kept in sessionStorage (keyed by project, scoped to the browser tab),
so minimizing the panel or navigating to another page of the site brings the same chat
back. The header's New chat button, or clearMessages() from useKookeeChat, discards
it and starts a fresh session. KookeeChat (inline) shows the same button in a header when
you pass title.
Controlling the widget
Pass hideLauncher to drop the floating button and open the panel from your own UI. Open
state follows the usual controlled/uncontrolled convention: open + onOpenChange to
control it, or defaultOpen to only set the initial value (read once on mount). The panel's
own close button reports through onOpenChange in both modes. Do not switch between the
two modes after mount. With hideLauncher and neither open nor defaultOpen, the panel
can never open.
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Ask AI</button>
<KookeeChatWidget
clientOptions={{ projectId }}
hideLauncher
open={open}
onOpenChange={setOpen}
/>launcherLabel="Need help?" shows a pill next to the launcher, fading in shortly after the
page loads. Clicking it opens the chat; its X dismisses it permanently for that browser
(localStorage), and it stays hidden while the panel is open.
zIndex and offset override the fixed-position layout. offset.x is the distance from
the side edge chosen by position (right for bottom-right, left for bottom-left),
offset.y from the bottom; numbers are pixels, strings are passed through as CSS lengths.
<KookeeChatWidget
clientOptions={{ projectId }}
zIndex={50}
offset={{ x: 16, y: 'calc(20px + env(safe-area-inset-bottom))' }}
/>The same values can be set from a stylesheet instead, via --kookee-widget-z,
--kookee-widget-offset-x and --kookee-widget-offset-y on .kookee-widget.
Script tag (no React)
The same widget ships as a single <script> for sites that do not use React — plain HTML,
Shopify, WordPress, Vue, Angular. React and every dependency are bundled, and the widget
renders inside a shadow root so your page's CSS and the widget's never touch.
<script async src="https://kookee.dev/chat/latest.js" data-api-key="YOUR_API_KEY"></script>The key is meant to be public; restrict it to your domains with the project's allowed
origins. Optional attributes: data-base-url, data-locale, data-title, data-greeting,
data-placeholder, data-launcher-label, data-position (bottom-right | bottom-left),
data-theme (light | dark | auto), data-primary-color.
For suggestions, callbacks or a custom launcher, omit data-api-key and initialize from
code. Every KookeeChatWidget prop except clientOptions and open is accepted:
<script src="https://kookee.dev/chat/latest.js"></script>
<script>
KookeeChat.init({
apiKey: 'YOUR_API_KEY',
suggestions: ['Pricing', 'Refunds'],
hideLauncher: true,
onSourceClick: (source) => { location.href = '/help/' + source.slug; },
});
document.querySelector('#ask-ai').addEventListener('click', () => KookeeChat.open());
</script>| Method | Description |
| --- | --- |
| init(config) | Mounts the widget. Throws if already initialized — call destroy() first |
| open() / close() / toggle() | Open state |
| sendMessage(text) | Sends a message as the user |
| clearMessages() | Clears the conversation and starts a new session |
| on(event, cb) | 'open', 'close', 'sourceClick'; returns an unsubscribe function |
| destroy() | Unmounts and removes the widget; listeners are kept |
Calls before init() log a warning and do nothing; on() works at any time. With async
on the tag, call the API from the script's onload or after the page's load event.
Citations are clickable only when onSourceClick is given or a sourceClick listener is
registered. Auto-init from data-* needs a classic external script (document.currentScript
is null for type="module" and for tag managers that inline the source) — use init()
there.
TypeScript projects that call the KookeeChat global from code can download
https://kookee.dev/chat/latest.d.ts into their types folder (it declares the global with no
imports). With this package installed, the equivalent is:
import type { KookeeChatApi } from '@kookee/react/chat';
declare global {
var KookeeChat: KookeeChatApi;
}The script is not in the npm package. It is built from src/widget/ with the rest of this
package (pnpm build writes dist/chat.global.js and dist/chat-global.d.ts).
Advanced: the generic hooks
The named hooks are thin wrappers over three generics, exported for anything not covered above:
import { useKookeeList, useKookeeQuery, useKookeeCollection, useKookee } from '@kookee/react';
const kookee = useKookee();
const { data } = useKookeeList(
(page, signal) => kookee.blog.list({ limit: 10, page }, signal),
'blog|10',
);useKookeeList— paginated responses, withloadMoreuseKookeeQuery— a single valueuseKookeeCollection— methods returning a bare array
The second argument is a cache key. It must reflect everything the callback closes
over — the callback is not in the dependency list, so a value missing from the key will
silently go stale. Pass null to keep the hook idle.
TypeScript
Types come from @kookee/sdk:
import type { BlogEntryListItem, BlogEntryDetail, PaginatedResponse } from '@kookee/sdk';License
MIT
