CMS API Reference for the Tanstack Start Supabase SaaS Kit

Complete API reference for fetching, filtering, and rendering content from any CMS provider in Makerkit.

The CMS API provides a unified interface for fetching content regardless of your storage backend. The same code works with Keystatic, WordPress, Supabase, or any custom CMS client you create.

Creating a CMS Client

The createCmsClient function returns a client configured for your chosen provider:

import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();

The provider is determined by the CMS_CLIENT environment variable:

CMS_CLIENT=keystatic # Default
CMS_CLIENT=wordpress
CMS_CLIENT=supabase # Requires plugin

You can also override the provider at runtime:

import { createCmsClient } from '@kit/cms';
// Force WordPress regardless of env var
const wpClient = await createCmsClient('wordpress');

Fetching Multiple Content Items

Use getContentItems() to retrieve lists of content with filtering and pagination:

import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const { items, total } = await client.getContentItems({
collection: 'posts',
limit: 10,
offset: 0,
sortBy: 'publishedAt',
sortDirection: 'desc',
status: 'published',
});

Options Reference

OptionTypeDefaultDescription
collectionstringRequiredThe collection to query (posts, documentation, changelog)
limitnumber10Maximum items to return
offsetnumber0Number of items to skip (for pagination)
sortBy'publishedAt' | 'order' | 'title''publishedAt'Field to sort by
sortDirection'asc' | 'desc''asc'Sort direction
status'published' | 'draft' | 'review' | 'pending''published'Filter by content status
categoriesstring[]-Filter by category slugs
tagsstring[]-Filter by tag slugs
languagestring-Filter by language code
contentbooleantrueWhether to fetch full content (set false for list views)
parentIdsstring[]-Filter by parent content IDs (for hierarchical content)

Pagination Example

apps/web/src/routes/(marketing)/blog/index.tsx

import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { createCmsClient } from '@kit/cms';
const getPostsPage = createServerFn({ method: 'GET' })
.validator(z.object({ page: z.number(), perPage: z.number().default(10) }))
.handler(async ({ data }) => {
const client = await createCmsClient();
return client.getContentItems({
collection: 'posts',
limit: data.perPage,
offset: (data.page - 1) * data.perPage,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
});
export const Route = createFileRoute('/(marketing)/blog/')({
validateSearch: (search: Record<string, unknown>) => ({
page: typeof search.page === 'number' ? search.page : 1,
}),
loaderDeps: ({ search }) => ({ page: search.page }),
loader: ({ deps }) => getPostsPage({ data: { page: deps.page, perPage: 10 } }),
component: BlogList,
});
function BlogList() {
const { page } = Route.useSearch();
const { items, total } = Route.useLoaderData();
const totalPages = Math.ceil(total / 10);
return (
<div>
{items.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.description}</p>
</article>
))}
<nav>
Page {page} of {totalPages}
</nav>
</div>
);
}

Filtering by Category

const { items } = await client.getContentItems({
collection: 'posts',
categories: ['tutorials', 'guides'],
limit: 5,
});

List View Optimization

For list views where you only need titles and descriptions, skip content fetching:

const { items } = await client.getContentItems({
collection: 'posts',
content: false, // Don't fetch full content
limit: 20,
});

Fetching a Single Content Item

Use getContentItemBySlug() to retrieve a specific piece of content:

import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug: 'getting-started',
collection: 'posts',
});
if (!post) {
// Handle not found
}

Options Reference

OptionTypeDefaultDescription
slugstringRequiredThe URL slug of the content item
collectionstringRequiredThe collection to search
status'published' | 'draft' | 'review' | 'pending''published'Required status for the item

Draft Preview

To preview unpublished content (e.g., for admin users):

const draft = await client.getContentItemBySlug({
slug: 'upcoming-feature',
collection: 'posts',
status: 'draft',
});

Content Item Shape

All CMS providers return items matching this TypeScript interface:

interface ContentItem {
id: string;
title: string;
label: string | undefined;
slug: string;
url: string;
description: string | undefined;
content: unknown; // Provider-specific format
publishedAt: string; // ISO date string
image: string | undefined;
status: 'draft' | 'published' | 'review' | 'pending';
categories: Category[];
tags: Tag[];
order: number;
parentId: string | undefined;
children: ContentItem[];
collapsible?: boolean;
collapsed?: boolean;
}
interface Category {
id: string;
name: string;
slug: string;
}
interface Tag {
id: string;
name: string;
slug: string;
}

Rendering Content

Content format varies by provider (Markdoc nodes, HTML, React nodes). Use the ContentRenderer component for provider-agnostic rendering:

apps/web/src/routes/(marketing)/blog/$slug.tsx

import { createFileRoute, notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { ContentRenderer, createCmsClient } from '@kit/cms';
const getArticle = createServerFn({ method: 'GET' })
.validator(z.object({ slug: z.string() }))
.handler(async ({ data }) => {
const client = await createCmsClient();
const article = await client.getContentItemBySlug({
slug: data.slug,
collection: 'posts',
});
if (!article) {
throw notFound();
}
return article;
});
export const Route = createFileRoute('/(marketing)/blog/$slug')({
loader: ({ params }) => getArticle({ data: { slug: params.slug } }),
component: ArticlePage,
});
function ArticlePage() {
const article = Route.useLoaderData();
return (
<article>
<header>
<h1>{article.title}</h1>
{article.description && <p>{article.description}</p>}
<time dateTime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString()}
</time>
</header>
<ContentRenderer content={article.content} />
<footer>
{article.categories.map((cat) => (
<span key={cat.id}>{cat.name}</span>
))}
</footer>
</article>
);
}

Working with Categories and Tags

Fetch All Categories

const categories = await client.getCategories({
limit: 50,
offset: 0,
});

Fetch a Category by Slug

const category = await client.getCategoryBySlug('tutorials');
if (category) {
// Fetch posts in this category
const { items } = await client.getContentItems({
collection: 'posts',
categories: [category.slug],
});
}

Fetch All Tags

const tags = await client.getTags({
limit: 100,
});

Fetch a Tag by Slug

const tag = await client.getTagBySlug('react');

Building Dynamic Pages

Blog Post Page

apps/web/src/routes/(marketing)/blog/$slug.tsx

import { createFileRoute, notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { ContentRenderer, createCmsClient } from '@kit/cms';
const getPost = createServerFn({ method: 'GET' })
.validator(z.object({ slug: z.string() }))
.handler(async ({ data }) => {
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug: data.slug,
collection: 'posts',
});
if (!post) {
throw notFound();
}
return post;
});
export const Route = createFileRoute('/(marketing)/blog/$slug')({
loader: ({ params }) => getPost({ data: { slug: params.slug } }),
// `head` replaces Next.js `generateMetadata`; it reads the resolved loader data.
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.title },
{ name: 'description', content: loaderData?.description },
...(loaderData?.image
? [{ property: 'og:image', content: loaderData.image }]
: []),
],
}),
component: BlogPostPage,
});
function BlogPostPage() {
const post = Route.useLoaderData();
return (
<article>
<h1>{post.title}</h1>
<ContentRenderer content={post.content} />
</article>
);
}

CMS-Powered Static Pages

Store pages like Terms of Service or Privacy Policy in your CMS:

apps/web/src/routes/$slug.tsx

import { createFileRoute, notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { ContentRenderer, createCmsClient } from '@kit/cms';
const getPage = createServerFn({ method: 'GET' })
.validator(z.object({ slug: z.string() }))
.handler(async ({ data }) => {
const client = await createCmsClient();
const page = await client.getContentItemBySlug({
slug: data.slug,
collection: 'pages', // Create this collection in your CMS
});
if (!page) {
throw notFound();
}
return page;
});
export const Route = createFileRoute('/$slug')({
loader: ({ params }) => getPage({ data: { slug: params.slug } }),
component: StaticPage,
});
function StaticPage() {
const page = Route.useLoaderData();
return (
<div>
<h1>{page.title}</h1>
<ContentRenderer content={page.content} />
</div>
);
}

Caching Strategies

Fetch in Route Loaders

There are no React Server Components in Tanstack Start, so fetch content in a route loader (via a server function). The loader runs once per navigation and hands its result to the component through Route.useLoaderData() — no react cache() wrapper is needed for request deduplication:

import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { createCmsClient } from '@kit/cms';
export const getPost = createServerFn({ method: 'GET' })
.validator(z.object({ slug: z.string() }))
.handler(async ({ data }) => {
const client = await createCmsClient();
return client.getContentItemBySlug({
slug: data.slug,
collection: 'posts',
});
});

Client-Side Caching

Tanstack Router caches loader results in memory. Control how long a route's loader data stays fresh with staleTime (and gcTime) in the route options, so revisiting a route within the window skips a refetch:

import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { createCmsClient } from '@kit/cms';
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
const client = await createCmsClient();
return client.getContentItems({ collection: 'posts', limit: 10 });
});
export const Route = createFileRoute('/(marketing)/blog/')({
// Keep loader data fresh for an hour before refetching on navigation.
staleTime: 1000 * 60 * 60,
loader: () => getPosts(),
});

For static content, prerender routes at build time via your Tanstack Start prerender configuration instead of fetching per request.

Provider-Specific Notes

Keystatic

  • Collections: posts, documentation, changelog (configurable in keystatic.config.ts)
  • Categories and tags are stored as arrays of strings
  • Content is Markdoc, rendered via @kit/keystatic/renderer

WordPress

  • Collections map to WordPress content types: use posts for posts, pages for pages
  • Categories and tags use WordPress's native taxonomy system
  • Language filtering uses tags (add en, de, etc. tags to posts)
  • Content is HTML, rendered via @kit/wordpress/renderer

Supabase

  • Uses the content_items, categories, and tags tables
  • Requires the Supabase CMS plugin installation
  • Content can be HTML or any format you store
  • Works with Supamode for admin UI

Next Steps