Kookee stores SEO fields per entry, but it does not render your pages — so getting them into your HTML <head> is your application's job.
The fields
Every entry carries:
metaTitle— up to 70 characters, the search result headlinemetaDescription— up to 160 characters, the snippet beneath it
Both are optional, and returned on list items as well as single-entry fetches.
The limits match what search engines actually display. Longer text is not rejected by them, just truncated mid-sentence, which looks careless.
Rendering them
Fall back to the entry's own title and excerpt when the SEO fields are empty:
// Next.js App Router
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const post = await kookee.blog.getBySlug(slug);
return {
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? stripHtml(post.excerptHtml),
openGraph: {
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? undefined,
type: 'article',
publishedTime: post.publishedAt ?? undefined,
},
};
}
Note excerptHtml is HTML, not plain text. Strip the tags before using it as a description — a <p> in a meta tag is not what you want.
Sitemaps
The SDK ships builders that do all of this for you — getEntrySeo for a page's metadata, buildSitemap, buildFeed and buildLlmsTxt for the site-wide files. Reach for those first; see Sitemaps, RSS feeds and llms.txt.
The rest of this section is the manual version, which is still worth understanding when you want a shape the builders do not produce.
List endpoints give you everything a sitemap needs. Page through them, since limit caps at 100:
async function getAllEntries(list: (params: { page: number; limit: number }) => Promise<any>) {
const entries = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const response = await list({ page, limit: 100 });
entries.push(...response.data);
totalPages = response.totalPages;
page++;
}
return entries;
}
export default async function sitemap() {
const [posts, articles, pages] = await Promise.all([
getAllEntries((p) => kookee.blog.list(p)),
getAllEntries((p) => kookee.help.list(p)),
getAllEntries((p) => kookee.pages.list(p)),
]);
return [
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt ?? post.createdAt,
})),
...articles.map((article) => ({
url: `https://example.com/help/${article.slug}`,
lastModified: article.updatedAt ?? article.createdAt,
})),
...pages.map((page) => ({
url: `https://example.com/${page.slug}`,
lastModified: page.updatedAt ?? page.createdAt,
})),
];
}
Cache the sitemap. Regenerating it per request means several paginated API calls each time a crawler asks.
Only published entries are returned, so drafts never leak into a sitemap.
Locales and canonical URLs
This is where multi-language setups usually go wrong. Because fallback defaults to true, requesting a missing translation returns your default-locale content — so /de/blog/hello-world can serve the same English text as /en/blog/hello-world.
Two identical pages on two URLs is duplicate content. Check what you actually got:
const post = await kookee.blog.getBySlug(slug, { locale: 'de' });
const isFallback = post.locale !== 'de';
Then either set a canonical URL pointing at the real language, or return a 404 for locales that have no translation. Which is right depends on whether you would rather the fallback page be indexed at all.
For genuinely translated pages, emit hreflang links from the translation list:
const translations = await kookee.blog.getTranslationsBySlug(slug);
const alternates = Object.entries(translations).map(([locale, translation]) => ({
hrefLang: locale,
href: `https://example.com/${locale}/blog/${translation.slug}`,
}));
The result is an object keyed by locale, not an array — each value is a summary with id, slug, locale and title, which is all hreflang needs. Slugs differ per locale, so build these from each translation's own slug, and reach for getBySlug(translation.slug, { locale }) when you need that translation's body or meta fields.
Paginated lists
Give each page of a list its own canonical URL: /blog for the first page, /blog?page=2 for the second, and so on. Pointing every page at /blog tells search engines that page two duplicates page one, and the posts that only it links to become harder to find. Filtered views, such as /blog?tag=react, can point at the unfiltered list instead, which helps keep the combinations of tags, searches and pages out of the index. Render the pagination as links, as the Pagination component in @kookee/react/entry does, so crawlers can follow it.
Structured data
contentHtml plus the entry fields cover what article structured data needs:
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.metaDescription ?? undefined,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author.name },
};
Practical notes
Write meta fields for the search result, not the page. A headline that works on your blog can read badly out of context.
Translate the meta fields too. A translated article with English metadata gets indexed in the wrong language — an easy thing to miss, since the page itself looks correct.
Set lastModified from updatedAt. Crawlers use it to decide what to re-fetch, and a sitemap where everything looks freshly modified is no more useful than one where nothing does.