Pages hold static content — Terms of Service, Privacy Policy, About, careers copy — that changes occasionally and should never require a deploy.
Listing pages
const response = await kookee.pages.list({
page: 1,
limit: 20,
search: 'privacy',
});
Useful for a sitemap, a footer link list, or a legal index page.
Fetching one page
const page = await kookee.pages.getBySlug('terms-of-service');
page.title;
page.contentHtml;
page.updatedAt;
getById(id) also exists. As with every module, contentHtml comes only from a single-entry fetch, never from list items.
One catch-all route
The pattern that pays off is a single dynamic route that looks up whatever slug it receives:
// app/[slug]/page.tsx — Next.js App Router
import { notFound } from 'next/navigation';
import { KookeeApiError } from '@kookee/sdk';
import { EntryContent } from '@kookee/react/entry';
import { kookee } from '@/lib/kookee';
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let page;
try {
page = await kookee.pages.getBySlug(slug);
} catch (error) {
if (error instanceof KookeeApiError && error.status === 404) notFound();
throw error;
}
return (
<article className="prose">
<h1>{page.title}</h1>
<EntryContent entry={page} />
</article>
);
}
Now publishing a new page in the dashboard puts it live at its slug immediately. Nobody opens the repository to add a route.
EntryContent renders the body in an element with the kookee-entry-content class, which the SDK's content stylesheet is scoped to. Without React, render contentHtml into an element with that class yourself.
Place this route carefully — a catch-all at the root will shadow more specific routes if your framework resolves it first. Check your framework's precedence rules, or nest pages under a prefix such as /legal/[slug].
Showing a last-updated date
Legal pages usually need one, and updatedAt gives it to you without manual maintenance:
<p>Last updated: {new Date(page.updatedAt).toLocaleDateString()}</p>
A table of contents for long pages
Terms and privacy policies run long. TableOfContents from @kookee/react/entry lists the page's headings as links, and renders nothing when there are fewer than two:
import { EntryContent, TableOfContents, extractHeadings } from '@kookee/react/entry';
<TableOfContents headings={extractHeadings(page.contentHtml)} />
<EntryContent entry={page} />
TrackedTableOfContents from @kookee/react/entry/client also highlights the section being read; see Build a blog with the React components.
Static generation
For a framework that pre-renders, enumerate the slugs at build time:
export async function generateStaticParams() {
const { data } = await kookee.pages.list({ limit: 100 });
return data.map((page) => ({ slug: page.slug }));
}
One consequence worth planning for: a statically generated page will not change until the next build, which undoes the no-deploy benefit. Use incremental revalidation if your framework supports it, or render pages on demand with a short cache.
Translations
const page = await kookee.pages.getBySlug('terms-of-service', { locale: 'de', fallback: true });
const translations = await kookee.pages.getTranslationsBySlug('terms-of-service');
// { en: { id, slug, locale, title }, de: { id, slug, locale, title } }
Object.entries(translations).map(([locale, translation]) => `/${locale}/${translation.slug}`);
getTranslationsBySlug returns an object keyed by locale, not an array — iterate it with Object.entries(). Each value is a summary carrying only id, slug, locale and title; load the page behind one with getBySlug(translation.slug, { locale }).
Slugs can differ per locale, so build language-switcher links from each translation's own slug rather than swapping a locale prefix in the URL.
For legal content specifically, consider fallback: false. Silently serving English terms to a German user is worse than showing a clear "not available in your language" notice, since translated legal text can carry different obligations.