Creating a Custom CMS Client

Build your own CMS client to connect Sanity, Strapi, Contentful, Payload CMS, or any headless CMS to Makerkit.

Create a custom CMS client to connect any headless CMS to Makerkit. Extend the CmsClient abstract class, implement the required methods to fetch content from your CMS API, and register your client. Your custom implementation plugs into Makerkit's existing blog, documentation, and content pages without modifying application code.

A custom CMS client is a class that extends CmsClient and implements the required methods to fetch content from a third-party CMS API.

  • Build a custom client when: you're using Sanity, Strapi, Contentful, Payload CMS, or any headless CMS not included by default.
  • Use the existing clients when: Keystatic or WordPress meet your needs - no custom code required.

This page is part of the CMS Integration documentation.

The CmsClient Interface

Your custom client must extend CmsClient and implement these abstract methods:

import { CmsClient, Cms } from '@kit/cms-types';
export abstract class CmsClient {
// Fetch paginated content items
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;
}): 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>;
}

The full interface is defined at packages/cms/types/src/cms-client.ts.

Example: HTTP API Client

Here's a complete implementation for a custom HTTP API:

import { CmsClient, Cms } from '@kit/cms-types';
export class CustomApiClient extends CmsClient {
private baseUrl: string;
constructor(baseUrl: string) {
super();
this.baseUrl = baseUrl;
}
async getContentItems(
options?: Cms.GetContentItemsOptions
): Promise<{ total: number; items: Cms.ContentItem[] }> {
const response = await fetch(`${this.baseUrl}/content`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options ?? {}),
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getContentItemBySlug(params: {
slug: string;
collection: string;
}): Promise<Cms.ContentItem | undefined> {
const response = await fetch(
`${this.baseUrl}/content/${params.collection}/${params.slug}`
);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getCategories(
options?: Cms.GetCategoriesOptions
): Promise<Cms.Category[]> {
const response = await fetch(`${this.baseUrl}/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options ?? {}),
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getCategoryBySlug(slug: string): Promise<Cms.Category | undefined> {
const response = await fetch(`${this.baseUrl}/categories/${slug}`);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]> {
const response = await fetch(`${this.baseUrl}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options ?? {}),
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getTagBySlug(slug: string): Promise<Cms.Tag | undefined> {
const response = await fetch(`${this.baseUrl}/tags/${slug}`);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
}

Registering Your Client

Create a factory function that returns your client:

// packages/cms/custom/src/index.ts
import { CustomApiClient } from './custom-api-client';
export function createCustomCmsClient() {
const baseUrl = process.env.CUSTOM_CMS_API_URL;
if (!baseUrl) {
throw new Error('CUSTOM_CMS_API_URL environment variable is required');
}
return new CustomApiClient(baseUrl);
}

Then update the CMS client factory at packages/cms/core/src/create-cms-client.ts:

import { createCustomCmsClient } from '@kit/cms-custom';
export async function createCmsClient() {
const cmsClient = process.env.CMS_CLIENT ?? 'keystatic';
switch (cmsClient) {
case 'keystatic':
return createKeystaticClient();
case 'wordpress':
return createWordpressClient();
case 'custom':
return createCustomCmsClient();
default:
throw new Error(`Unknown CMS client: ${cmsClient}`);
}
}

Using CMS SDKs

For established CMS platforms, use their official SDKs:

Sanity Example

import { createClient } from '@sanity/client';
import { CmsClient, Cms } from '@kit/cms-types';
export class SanityClient extends CmsClient {
private client;
constructor() {
super();
this.client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
useCdn: true,
});
}
async getContentItems(
options?: Cms.GetContentItemsOptions
): Promise<{ total: number; items: Cms.ContentItem[] }> {
const { collection = 'post', limit = 10, offset = 0 } = options ?? {};
const query = `{
"items": *[_type == $type] | order(publishedAt desc) [$start...$end] {
"id": _id,
"slug": slug.current,
title,
description,
"publishedAt": publishedAt,
"content": body
},
"total": count(*[_type == $type])
}`;
const result = await this.client.fetch(query, {
type: collection,
start: offset,
end: offset + limit,
});
return result;
}
// ... implement remaining methods
}

Common Pitfalls

  • Using implements instead of extends: CmsClient is an abstract class, not an interface. Use extends CmsClient, not implements CmsClient.
  • Forgetting error handling: CMS APIs fail. Wrap fetch calls in try/catch and handle network errors, rate limits, and authentication failures.
  • Not returning undefined for missing items: getContentItemBySlug() should return undefined (not throw) when an item doesn't exist. Callers expect to check for undefined.
  • Ignoring pagination parameters: Implement limit and offset properly. Returning all items when limit: 10 is passed breaks pagination.
  • Hardcoding API URLs: Use environment variables for API URLs and credentials. Never commit secrets to your repository.
  • Forgetting to register the client: After creating your class, update the factory function in create-cms-client.ts and add your CMS type to the switch statement.

Frequently Asked Questions

Which CMS platforms can I integrate?
Any CMS with an API: Sanity, Strapi, Contentful, Payload CMS, Directus, Ghost, Prismic, and more. If it has an API, you can build a client.
Do I need to implement all methods?
Yes. All abstract methods must be implemented, even if your CMS doesn't support a feature. Return empty arrays for unsupported features.
How do I handle authentication?
Pass API keys or tokens via environment variables. Initialize credentials in your client's constructor.
Can I use GraphQL instead of REST?
Yes. The client interface doesn't care about your transport. Use Apollo, urql, or fetch with GraphQL queries.
How do I test my custom client?
Create unit tests that mock API responses. Test each method with various inputs including edge cases like empty results and 404s.

Back to: CMS Integration →