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

*Canonical: https://makerkit.dev/docs/tanstack-supabase/content/cms-api*

---

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:

```tsx
import { createCmsClient } from '@kit/cms';

const client = await createCmsClient();
```

The provider is determined by the `CMS_CLIENT` environment variable:

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

You can also override the provider at runtime:

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

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

```tsx {% title="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

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

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

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

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

## Content Item Shape

All CMS providers return items matching this TypeScript interface:

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

```tsx {% title="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

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

### Fetch a Category by Slug

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

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

### Fetch a Tag by Slug

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

## Building Dynamic Pages

### Blog Post Page

```tsx {% title="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:

```tsx {% title="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>
  );
}
```

{% alert type="default" title="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.
{% /alert %}

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

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

```tsx
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](/supabase-cms) for admin UI

## Next Steps

- [Keystatic Setup](/docs/tanstack-supabase/content/keystatic): Configure local or GitHub storage
- [WordPress Setup](/docs/tanstack-supabase/content/wordpress): Connect to WordPress REST API
- [Supabase CMS Plugin](/docs/tanstack-supabase/content/supabase): Store content in your database
- [Custom CMS Client](/docs/tanstack-supabase/content/creating-your-own-cms-client): Build integrations for Sanity, Contentful, etc.
