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 # DefaultCMS_CLIENT=wordpressCMS_CLIENT=supabase # Requires pluginYou can also override the provider at runtime:
import { createCmsClient } from '@kit/cms';// Force WordPress regardless of env varconst 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
| Option | Type | Default | Description |
|---|---|---|---|
collection | string | Required | The collection to query (posts, documentation, changelog) |
limit | number | 10 | Maximum items to return |
offset | number | 0 | Number 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 |
categories | string[] | - | Filter by category slugs |
tags | string[] | - | Filter by tag slugs |
language | string | - | Filter by language code |
content | boolean | true | Whether to fetch full content (set false for list views) |
parentIds | string[] | - | 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
| Option | Type | Default | Description |
|---|---|---|---|
slug | string | Required | The URL slug of the content item |
collection | string | Required | The 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> );}Create the pages collection
This example assumes you've added a pages collection to your CMS configuration. By default, Makerkit includes posts, documentation, and changelog collections.
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 inkeystatic.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
postsfor posts,pagesfor 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, andtagstables - Requires the Supabase CMS plugin installation
- Content can be HTML or any format you store
- Works with Supamode for admin UI
Next Steps
- Keystatic Setup: Configure local or GitHub storage
- WordPress Setup: Connect to WordPress REST API
- Supabase CMS Plugin: Store content in your database
- Custom CMS Client: Build integrations for Sanity, Contentful, etc.