Building a Custom CMS Client for the Next.js Supabase SaaS Kit
Implement the CMS interface to integrate Sanity, Contentful, Strapi, Payload, or any headless CMS with Makerkit.
Makerkit's CMS interface is designed to be extensible. If you're using a CMS that isn't supported out of the box, you can create your own client by implementing the CmsClient abstract class.
This guide walks through building a custom CMS client using a fictional HTTP API as an example. The same pattern works for Sanity, Contentful, Strapi, Payload, or any headless CMS.
The CMS Interface
Your client must implement these methods:
import { Cms, CmsClient } from '@kit/cms-types';export abstract class CmsClient { // Fetch multiple content items with filtering and pagination abstract getContentItems( options?: Cms.GetContentItemsOptions ): Promise<{ total: number; items: Cms.ContentItem[]; }>; // Fetch a single content item by slug abstract getContentItemBySlug(params: { slug: string; collection: string; status?: Cms.ContentItemStatus; }): Promise<Cms.ContentItem | undefined>; // Fetch categories abstract getCategories( options?: Cms.GetCategoriesOptions ): Promise<Cms.Category[]>; // Fetch a single category by slug abstract getCategoryBySlug( slug: string ): Promise<Cms.Category | undefined>; // Fetch tags abstract getTags( options?: Cms.GetTagsOptions ): Promise<Cms.Tag[]>; // Fetch a single tag by slug abstract getTagBySlug( slug: string ): Promise<Cms.Tag | undefined>;}Type Definitions
The CMS types are defined in packages/cms/types/src/cms-client.ts:
export namespace Cms { export interface ContentItem { id: string; title: string; label: string | undefined; url: string; description: string | undefined; content: unknown; publishedAt: string; image: string | undefined; status: ContentItemStatus; slug: string; categories: Category[]; tags: Tag[]; order: number; children: ContentItem[]; parentId: string | undefined; collapsible?: boolean; collapsed?: boolean; } export type ContentItemStatus = 'draft' | 'published' | 'review' | 'pending'; export interface Category { id: string; name: string; slug: string; } export interface Tag { id: string; name: string; slug: string; } export interface GetContentItemsOptions { collection: string; limit?: number; offset?: number; categories?: string[]; tags?: string[]; content?: boolean; parentIds?: string[]; language?: string | undefined; sortDirection?: 'asc' | 'desc'; sortBy?: 'publishedAt' | 'order' | 'title'; status?: ContentItemStatus; } export interface GetCategoriesOptions { slugs?: string[]; limit?: number; offset?: number; } export interface GetTagsOptions { slugs?: string[]; limit?: number; offset?: number; }}Example Implementation
Here's a complete example for a fictional HTTP API:
packages/cms/my-cms/src/my-cms-client.ts
import { Cms, CmsClient } from '@kit/cms-types';const API_URL = process.env.MY_CMS_API_URL;const API_KEY = process.env.MY_CMS_API_KEY;export function createMyCmsClient() { return new MyCmsClient();}class MyCmsClient extends CmsClient { private async fetch<T>(endpoint: string, options?: RequestInit): Promise<T> { const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}`, ...options?.headers, }, }); if (!response.ok) { throw new Error(`CMS API error: ${response.status}`); } return response.json(); } async getContentItems( options: Cms.GetContentItemsOptions ): Promise<{ total: number; items: Cms.ContentItem[] }> { const params = new URLSearchParams(); params.set('collection', options.collection); if (options.limit) { params.set('limit', options.limit.toString()); } if (options.offset) { params.set('offset', options.offset.toString()); } if (options.status) { params.set('status', options.status); } if (options.sortBy) { params.set('sort_by', options.sortBy); } if (options.sortDirection) { params.set('sort_direction', options.sortDirection); } if (options.categories?.length) { params.set('categories', options.categories.join(',')); } if (options.tags?.length) { params.set('tags', options.tags.join(',')); } if (options.language) { params.set('language', options.language); } const data = await this.fetch<{ total: number; items: ApiContentItem[]; }>(`/content?${params.toString()}`); return { total: data.total, items: data.items.map(this.mapContentItem), }; } async getContentItemBySlug(params: { slug: string; collection: string; status?: Cms.ContentItemStatus; }): Promise<Cms.ContentItem | undefined> { try { const queryParams = new URLSearchParams({ collection: params.collection, }); if (params.status) { queryParams.set('status', params.status); } const data = await this.fetch<ApiContentItem>( `/content/${params.slug}?${queryParams.toString()}` ); return this.mapContentItem(data); } catch (error) { // Return undefined for 404s return undefined; } } async getCategories( options?: Cms.GetCategoriesOptions ): Promise<Cms.Category[]> { const params = new URLSearchParams(); if (options?.limit) { params.set('limit', options.limit.toString()); } if (options?.offset) { params.set('offset', options.offset.toString()); } if (options?.slugs?.length) { params.set('slugs', options.slugs.join(',')); } const data = await this.fetch<ApiCategory[]>( `/categories?${params.toString()}` ); return data.map(this.mapCategory); } async getCategoryBySlug(slug: string): Promise<Cms.Category | undefined> { try { const data = await this.fetch<ApiCategory>(`/categories/${slug}`); return this.mapCategory(data); } catch { return undefined; } } async getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]> { const params = new URLSearchParams(); if (options?.limit) { params.set('limit', options.limit.toString()); } if (options?.offset) { params.set('offset', options.offset.toString()); } if (options?.slugs?.length) { params.set('slugs', options.slugs.join(',')); } const data = await this.fetch<ApiTag[]>(`/tags?${params.toString()}`); return data.map(this.mapTag); } async getTagBySlug(slug: string): Promise<Cms.Tag | undefined> { try { const data = await this.fetch<ApiTag>(`/tags/${slug}`); return this.mapTag(data); } catch { return undefined; } } // Map API response to Makerkit's ContentItem interface private mapContentItem(item: ApiContentItem): Cms.ContentItem { return { id: item.id, title: item.title, label: item.label ?? undefined, slug: item.slug, url: `/${item.collection}/${item.slug}`, description: item.excerpt ?? undefined, content: item.body, publishedAt: item.published_at, image: item.featured_image ?? undefined, status: this.mapStatus(item.status), categories: (item.categories ?? []).map(this.mapCategory), tags: (item.tags ?? []).map(this.mapTag), order: item.sort_order ?? 0, parentId: item.parent_id ?? undefined, children: [], collapsible: item.collapsible ?? false, collapsed: item.collapsed ?? false, }; } private mapCategory(cat: ApiCategory): Cms.Category { return { id: cat.id, name: cat.name, slug: cat.slug, }; } private mapTag(tag: ApiTag): Cms.Tag { return { id: tag.id, name: tag.name, slug: tag.slug, }; } private mapStatus(status: string): Cms.ContentItemStatus { switch (status) { case 'live': case 'active': return 'published'; case 'draft': return 'draft'; case 'pending': case 'scheduled': return 'pending'; case 'review': return 'review'; default: return 'draft'; } }}// API response types (adjust to match your CMS)interface ApiContentItem { id: string; title: string; label?: string; slug: string; collection: string; excerpt?: string; body: unknown; published_at: string; featured_image?: string; status: string; categories?: ApiCategory[]; tags?: ApiTag[]; sort_order?: number; parent_id?: string; collapsible?: boolean; collapsed?: boolean;}interface ApiCategory { id: string; name: string; slug: string;}interface ApiTag { id: string; name: string; slug: string;}Registering Your Client
1. Add the CMS Type
Update the type definition:
packages/cms/types/src/cms.type.ts
export type CmsType = 'wordpress' | 'keystatic' | 'my-cms';2. Register the Client
Add your client to the registry:
packages/cms/core/src/create-cms-client.ts
import { CmsClient, CmsType } from '@kit/cms-types';import { createRegistry } from '@kit/shared/registry';const CMS_CLIENT = process.env.CMS_CLIENT as CmsType;const cmsRegistry = createRegistry<CmsClient, CmsType>();// Existing registrations...cmsRegistry.register('wordpress', async () => { const { createWordpressClient } = await import('@kit/wordpress'); return createWordpressClient();});cmsRegistry.register('keystatic', async () => { const { createKeystaticClient } = await import('@kit/keystatic'); return createKeystaticClient();});// Register your clientcmsRegistry.register('my-cms', async () => { const { createMyCmsClient } = await import('@kit/my-cms'); return createMyCmsClient();});export async function createCmsClient(type: CmsType = CMS_CLIENT) { return cmsRegistry.get(type);}3. Create a Content Renderer (Optional)
If your CMS returns content in a specific format, create a renderer:
packages/cms/core/src/content-renderer.tsx
cmsContentRendererRegistry.register('my-cms', async () => { const { MyCmsContentRenderer } = await import('@kit/my-cms/renderer'); return MyCmsContentRenderer;});Example renderer for HTML content:
packages/cms/my-cms/src/renderer.tsx
interface Props { content: unknown;}export function MyCmsContentRenderer({ content }: Props) { if (typeof content !== 'string') { return null; } return ( <div className="prose prose-lg" dangerouslySetInnerHTML={{ __html: content }} /> );}For Markdown content:
packages/cms/my-cms/src/renderer.tsx
import { marked } from 'marked';interface Props { content: unknown;}export function MyCmsContentRenderer({ content }: Props) { if (typeof content !== 'string') { return null; } const html = marked(content); return ( <div className="prose prose-lg" dangerouslySetInnerHTML={{ __html: html }} /> );}4. Set the Environment Variable
# .envCMS_CLIENT=my-cmsMY_CMS_API_URL=https://api.my-cms.comMY_CMS_API_KEY=your-api-keyReal-World Examples
Sanity
import { createClient } from '@sanity/client';import { Cms, CmsClient } from '@kit/cms-types';const client = createClient({ projectId: process.env.SANITY_PROJECT_ID, dataset: process.env.SANITY_DATASET, useCdn: true, apiVersion: '2024-01-01',});class SanityClient extends CmsClient { async getContentItems(options: Cms.GetContentItemsOptions) { const query = `*[_type == $collection && status == $status] | order(publishedAt desc) [$start...$end] { _id, title, slug, excerpt, body, publishedAt, mainImage, categories[]->{ _id, title, slug }, tags[]->{ _id, title, slug } }`; const params = { collection: options.collection, status: options.status ?? 'published', start: options.offset ?? 0, end: (options.offset ?? 0) + (options.limit ?? 10), }; const items = await client.fetch(query, params); const total = await client.fetch( `count(*[_type == $collection && status == $status])`, params ); return { total, items: items.map(this.mapContentItem), }; } // ... implement other methods}Contentful
import { createClient } from 'contentful';import { Cms, CmsClient } from '@kit/cms-types';const client = createClient({ space: process.env.CONTENTFUL_SPACE_ID!, accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,});class ContentfulClient extends CmsClient { async getContentItems(options: Cms.GetContentItemsOptions) { const response = await client.getEntries({ content_type: options.collection, limit: options.limit ?? 10, skip: options.offset ?? 0, order: ['-fields.publishedAt'], }); return { total: response.total, items: response.items.map(this.mapContentItem), }; } // ... implement other methods}Testing Your Client
Create tests to verify your implementation:
packages/cms/my-cms/src/__tests__/my-cms-client.test.ts
import { describe, it, expect, beforeAll } from 'vitest';import { createMyCmsClient } from '../my-cms-client';describe('MyCmsClient', () => { const client = createMyCmsClient(); it('fetches content items', async () => { const { items, total } = await client.getContentItems({ collection: 'posts', limit: 5, }); expect(items).toBeInstanceOf(Array); expect(typeof total).toBe('number'); if (items.length > 0) { expect(items[0]).toHaveProperty('id'); expect(items[0]).toHaveProperty('title'); expect(items[0]).toHaveProperty('slug'); } }); it('fetches a single item by slug', async () => { const item = await client.getContentItemBySlug({ slug: 'test-post', collection: 'posts', }); if (item) { expect(item.slug).toBe('test-post'); } }); it('returns undefined for non-existent slugs', async () => { const item = await client.getContentItemBySlug({ slug: 'non-existent-slug-12345', collection: 'posts', }); expect(item).toBeUndefined(); });});Next Steps
- CMS API Reference: Full API documentation
- CMS Overview: Compare CMS providers
- Check the Keystatic implementation for a complete example