SDK & Integration

Next.js integration guide

Updated 94 views

Next.js is the most common way people run Kookee, and the App Router fits it well — server components turn one API call into a page every visitor shares.

To skip the setup below, the Next.js starter is a finished site built this way, with SEO files and a webhook that refreshes pages when you publish, ready to deploy to Vercel.

The client

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

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

Use KOOKEE_API_KEY here rather than NEXT_PUBLIC_KOOKEE_API_KEY. Not because the key is secret — it is designed to be public — but because server-side fetching does not need it in the browser bundle, and keeping one variable per place it is used makes rotation simpler.

A listing page

tsx
// app/blog/page.tsx
import Link from 'next/link';
import { kookee } from '@/lib/kookee';

export default async function BlogPage() {
  const { data: posts } = await kookee.blog.list({ limit: 10 });

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Async server components mean no useEffect, no loading state, and one request per render instead of one per visitor.

A detail page with 404 handling

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { KookeeApiError } from '@kookee/sdk';
import { kookee } from '@/lib/kookee';

async function getPost(slug: string) {
  try {
    return await kookee.blog.getBySlug(slug);
  } catch (error) {
    if (error instanceof KookeeApiError && error.status === 404) return null;
    throw error;
  }
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article className="prose">
      <h1>{post.title}</h1>
      <div className="kookee-entry-content" dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  );
}

Re-throwing anything that is not a 404 matters. A 500 or an auth failure should surface as an error, not silently render your not-found page.

params is a Promise from Next.js 15 on, so await it before reading the slug.

The kookee-entry-content class is what the SDK's content stylesheet is scoped to; without it, code blocks stay unstyled.

Metadata

tsx
import type { Metadata } from 'next';

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  return {
    title: post.metaTitle ?? post.title,
    description: post.metaDescription ?? undefined,
  };
}

Next.js deduplicates identical fetches within a render, so calling getPost in both generateMetadata and the page does not cost two API requests.

Static generation

tsx
export async function generateStaticParams() {
  const { data } = await kookee.blog.list({ limit: 100 });
  return data.map((post) => ({ slug: post.slug }));
}

Remember the limit ceiling of 100. With more posts than that, page through them:

TypeScript
async function getAllPosts() {
  const posts = [];
  let page = 1;
  let totalPages = 1;

  while (page <= totalPages) {
    const response = await kookee.blog.list({ page, limit: 100 });
    posts.push(...response.data);
    totalPages = response.totalPages;
    page++;
  }

  return posts;
}

Revalidation

Fully static pages will not change until the next build, which gives up the no-deploy benefit of a CMS. Time-based revalidation is the simple fix:

tsx
export const revalidate = 300; // regenerate at most every 5 minutes

Five minutes is a reasonable default for content pages: near-instant for readers, and one API call per page per five minutes regardless of traffic.

If you want publishes to appear immediately, add an on-demand revalidation route and call it after publishing:

TypeScript
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';

export async function POST(request: Request) {
  const { secret, path } = await request.json();
  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ ok: false }, { status: 401 });
  }

  revalidatePath(path);
  return Response.json({ ok: true });
}

Guard it with a secret — an open revalidation endpoint is an easy way for someone to hammer your origin.

Ready-made pages with @kookee/react

@kookee/react has server-rendered components for blog, changelog, help center and roadmap pages. They take what your server components fetch as props, so the detail page above shrinks to a fetch and a component:

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { BlogPost } from '@kookee/react/blog';

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  if (!post) notFound();

  return <BlogPost post={post} classNames={{ content: 'prose' }} />;
}

See Using the React components with Next.js, TanStack Start or Vite for the rules on client components, next/image and links.

Styles

Import the stylesheets once in the root layout:

tsx
// app/layout.tsx
import '@kookee/sdk/styles/content.css';
import '@kookee/react/content.css'; // if you use the React components

The chat widget

@kookee/react/chat is marked 'use client' from version 1.9.0, so a Server Component can render KookeeChatWidget directly, as long as it passes only data. To pass a function, such as onSourceClick, render it from a 'use client' file of your own:

tsx
'use client';

import { useRouter } from 'next/navigation';
import { KookeeChatWidget } from '@kookee/react/chat';
import '@kookee/react/chat.css';

export function ChatWidget() {
  const router = useRouter();

  return (
    <KookeeChatWidget
      clientOptions={{ apiKey: process.env.NEXT_PUBLIC_KOOKEE_API_KEY! }}
      onSourceClick={(source) => router.push(`/help/${source.slug}`)}
    />
  );
}

The widget runs in the browser, so here the key does need the NEXT_PUBLIC_ prefix. It is the same API key — exposing it is what it is built for.

Because it runs in the browser, if the project's Allowed Origins in project settings lists any domains, your site's domain must be among them, including any preview deployment domains you want the widget to work on.

Was this article helpful?