Content Management for Next.js Supabase SaaS Applications
Integrate headless CMS solutions like Keystatic, WordPress, or Supabase to manage blog posts, documentation, and marketing content in your SaaS application.
Makerkit provides a flexible content management layer that abstracts the CMS implementation, allowing you to switch between different content backends without changing your application code.
Why a CMS abstraction?
Different projects have different content needs:
- Solo developers might prefer file-based content with Keystatic
- Content teams might need a full-featured CMS like WordPress
- Dynamic applications might store content in Supabase
Makerkit's CMS abstraction lets you start simple and scale to a more robust solution as your needs grow—without rewriting your templates.
Supported CMS providers
| Provider | Storage | Best For | Edge Compatible |
|---|---|---|---|
| Keystatic | Local JSON / GitHub | Solo developers, simple blogs | With GitHub mode |
| WordPress | External API | Content teams, existing sites | Yes |
| Supabase | Database | Dynamic content, user-generated | Yes |
Quick start with Keystatic
Keystatic is the default CMS and requires no setup for local development:
import { createCmsClient } from '@kit/cms';async function BlogPage() { const cms = await createCmsClient(); const posts = await cms.getContentItems({ collection: 'posts', limit: 10, }); return ( <div> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </article> ))} </div> );}Keystatic storage modes
| Mode | Description | Use Case |
|---|---|---|
| local | JSON files in repository | Development, simple sites |
| github | GitHub repository storage | Team collaboration, CI/CD |
| cloud | Keystatic Cloud | Managed hosting |
Configure the mode in your environment:
KEYSTATIC_STORAGE_KIND=local # or 'github' or 'cloud'The CMS API
The CMS API provides a unified interface for all content operations:
Fetching content items
import { createCmsClient } from '@kit/cms';const cms = await createCmsClient();// Get all postsconst posts = await cms.getContentItems({ collection: 'posts', limit: 20, offset: 0,});// Get a single postconst post = await cms.getContentItemBySlug({ collection: 'posts', slug: 'getting-started',});// Get posts by categoryconst tutorials = await cms.getContentItems({ collection: 'posts', where: { categories: { contains: 'tutorials' }, },});Content item structure
Content items follow a consistent structure across all providers:
interface ContentItem { id: string; slug: string; title: string; content: string; // MDX or HTML excerpt?: string; publishedAt: Date; updatedAt: Date; author?: { name: string; avatar?: string; }; image?: { url: string; alt?: string; }; categories?: string[]; tags?: string[];}Using WordPress
For teams with existing WordPress content or those who need a full-featured CMS:
# Set WordPress as the CMS providerCMS_CLIENT=wordpressWORDPRESS_API_URL=https://your-site.com/wp-jsonThe WordPress adapter translates WordPress posts to Makerkit's content format:
import { createCmsClient } from '@kit/cms';async function WordPressBlog() { const cms = await createCmsClient(); // Uses WordPress adapter const posts = await cms.getContentItems({ collection: 'posts', limit: 10, }); // Works exactly like Keystatic return <PostList posts={posts} />;}Using Supabase for content
Store content directly in your database for maximum flexibility:
CMS_CLIENT=supabaseCreate content tables in your database:
create table posts ( id uuid primary key default gen_random_uuid(), slug text unique not null, title text not null, content text, excerpt text, published_at timestamp with time zone, created_at timestamp with time zone default now(), updated_at timestamp with time zone default now());Creating a custom CMS client
Implement the CMS interface to connect any content source:
import { CmsClient, ContentItem } from '@kit/cms';export class CustomCmsClient implements CmsClient { async getContentItems(params: GetContentParams): Promise<ContentItem[]> { const response = await fetch(`${API_URL}/posts`); const data = await response.json(); return data.map(this.transformPost); } async getContentItemBySlug(params: GetBySlugParams): Promise<ContentItem | null> { const response = await fetch(`${API_URL}/posts/${params.slug}`); if (!response.ok) return null; return this.transformPost(await response.json()); } private transformPost(post: ExternalPost): ContentItem { return { id: post.id, slug: post.slug, title: post.title, content: post.body, publishedAt: new Date(post.date), // ... map other fields }; }}Best practices
1. Use consistent slugs
Slugs should be URL-friendly and consistent across environments:
// Good: lowercase, hyphenatedconst slug = 'getting-started-with-makerkit';// Avoid: spaces, special charactersconst bad = 'Getting Started!';2. Handle missing content gracefully
Always check for null results:
const post = await cms.getContentItemBySlug({ slug });if (!post) { notFound();}3. Cache content appropriately
Use Next.js caching for content that doesn't change frequently:
import { unstable_cache } from 'next/cache';const getCachedPosts = unstable_cache( async () => { const cms = await createCmsClient(); return cms.getContentItems({ collection: 'posts' }); }, ['blog-posts'], { revalidate: 3600 } // 1 hour);Content documentation
Explore the detailed documentation for each CMS provider:
- CMS Overview — Understanding the CMS abstraction
- CMS API — Complete API reference
- Keystatic — File-based content management
- WordPress — Headless WordPress integration
- Supabase CMS — Database-backed content
- Custom CMS Client — Build your own adapter