Content Modules

Working with the Pages module

Updated 91 views

Pages hold static content — Terms of Service, Privacy Policy, About, careers copy — that changes occasionally and should never require a deploy.

Listing pages

TypeScript
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

TypeScript
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:

tsx
// 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:

tsx
<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:

tsx
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:

TypeScript
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

TypeScript
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.

Was this article helpful?