Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

467
docs/content/cms-api.mdoc Normal file
View File

@@ -0,0 +1,467 @@
---
status: "published"
label: "CMS API"
title: "CMS API Reference for the Next.js Supabase SaaS Kit"
description: "Complete API reference for fetching, filtering, and rendering content from any CMS provider in Makerkit."
order: 1
---
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
import { createCmsClient } from '@kit/cms';
import { cache } from 'react';
const getPostsPage = cache(async (page: number, perPage = 10) => {
const client = await createCmsClient();
return client.getContentItems({
collection: 'posts',
limit: perPage,
offset: (page - 1) * perPage,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
});
// Usage in a Server Component
async function BlogList({ page }: { page: number }) {
const { items, total } = await getPostsPage(page);
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
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function ArticlePage({ slug }: { slug: string }) {
const client = await createCmsClient();
const article = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!article) {
notFound();
}
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="app/[locale]/(marketing)/blog/[slug]/page.tsx" %}
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const client = await createCmsClient();
const { items } = await client.getContentItems({
collection: 'posts',
content: false,
limit: 1000,
});
return items.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
return {};
}
return {
title: post.title,
description: post.description,
openGraph: {
images: post.image ? [post.image] : [],
},
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
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="app/[slug]/page.tsx" %}
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ slug: string }>;
}
export default async function StaticPage({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const page = await client.getContentItemBySlug({
slug,
collection: 'pages', // Create this collection in your CMS
});
if (!page) {
notFound();
}
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
### React Cache
Wrap CMS calls with React's `cache()` for request deduplication:
```tsx
import { createCmsClient } from '@kit/cms';
import { cache } from 'react';
export const getPost = cache(async (slug: string) => {
const client = await createCmsClient();
return client.getContentItemBySlug({
slug,
collection: 'posts',
});
});
```
### Next.js Data Cache
The CMS client respects Next.js caching. For static content, pages are cached at build time with `generateStaticParams()`.
For dynamic content that should revalidate:
```tsx
import { unstable_cache } from 'next/cache';
import { createCmsClient } from '@kit/cms';
const getCachedPosts = unstable_cache(
async () => {
const client = await createCmsClient();
return client.getContentItems({ collection: 'posts', limit: 10 });
},
['posts-list'],
{ revalidate: 3600 } // Revalidate every hour
);
```
## 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/next-supabase-turbo/content/keystatic): Configure local or GitHub storage
- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API
- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build integrations for Sanity, Contentful, etc.

128
docs/content/cms.mdoc Normal file
View File

@@ -0,0 +1,128 @@
---
status: "published"
title: "CMS Integration in the Next.js Supabase SaaS Kit"
label: "CMS"
description: "Makerkit's CMS interface abstracts content storage, letting you swap between Keystatic, WordPress, or Supabase without changing your application code."
order: 0
---
Makerkit provides a unified CMS interface that decouples your application from the underlying content storage. Write your content queries once, then swap between Keystatic, WordPress, or Supabase without touching your React components.
This abstraction means you can start with local Markdown files during development, then switch to WordPress for a content team, or Supabase for a database-driven approach, all without rewriting your data fetching logic.
## Supported CMS Providers
Makerkit ships with two built-in CMS implementations and one plugin:
| Provider | Storage | Best For | Edge Compatible |
|----------|---------|----------|-----------------|
| [Keystatic](/docs/next-supabase-turbo/content/keystatic) | Local files or GitHub | Solo developers, Git-based workflows | GitHub mode only |
| [WordPress](/docs/next-supabase-turbo/content/wordpress) | WordPress REST API | Content teams, existing WordPress sites | Yes |
| [Supabase](/docs/next-supabase-turbo/content/supabase) | PostgreSQL via Supabase | Database-driven content, custom admin | Yes |
You can also [create your own CMS client](/docs/next-supabase-turbo/content/creating-your-own-cms-client) for providers like Sanity, Contentful, or Strapi.
## How It Works
The CMS interface consists of three layers:
1. **CMS Client**: An abstract class that defines methods like `getContentItems()` and `getContentItemBySlug()`. Each provider implements this interface.
2. **Content Renderer**: A React component that knows how to render content from each provider (Markdoc for Keystatic, HTML for WordPress, etc.).
3. **Registry**: A dynamic import system that loads the correct client based on the `CMS_CLIENT` environment variable.
```tsx
// This code works with any CMS provider
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const { items } = await client.getContentItems({
collection: 'posts',
limit: 10,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
```
The `CMS_CLIENT` environment variable determines which implementation gets loaded:
```bash
CMS_CLIENT=keystatic # Default - file-based content
CMS_CLIENT=wordpress # WordPress REST API
CMS_CLIENT=supabase # Supabase database (requires plugin)
```
## Default Collections
Keystatic ships with three pre-configured collections:
- **posts**: Blog posts with title, description, categories, tags, and Markdoc content
- **documentation**: Hierarchical docs with parent-child relationships and ordering
- **changelog**: Release notes and updates
WordPress maps to its native content types (posts and pages). Supabase uses the `content_items` table with flexible metadata.
## Choosing a Provider
**Choose Keystatic if:**
- You're a solo developer or small team
- You want version-controlled content in your repo
- You prefer Markdown/Markdoc for writing
- You don't need real-time collaborative editing
**Choose WordPress if:**
- You have an existing WordPress site
- Your content team knows WordPress
- You need its plugin ecosystem (SEO, forms, etc.)
- You want a battle-tested admin interface
**Choose Supabase if:**
- You want content in your existing database
- You need row-level security on content
- You're building a user-generated content feature
- You want to use [Supamode](/supabase-cms) as your admin
## Quick Start
By default, Makerkit uses Keystatic with local storage. No configuration needed.
To switch providers, set the environment variable and follow the provider-specific setup:
```bash
# .env
CMS_CLIENT=keystatic
```
Then use the [CMS API](/docs/next-supabase-turbo/content/cms-api) to fetch content in your components:
```tsx
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function BlogPost({ slug }: { slug: string }) {
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<ContentRenderer content={post.content} />
</article>
);
}
```
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation for fetching and filtering content
- [Keystatic Setup](/docs/next-supabase-turbo/content/keystatic): Configure local or GitHub storage
- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API
- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build your own integration

View File

@@ -0,0 +1,629 @@
---
status: "published"
label: "Custom CMS"
title: "Building a Custom CMS Client for the Next.js Supabase SaaS Kit"
description: "Implement the CMS interface to integrate Sanity, Contentful, Strapi, Payload, or any headless CMS with Makerkit."
order: 5
---
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:
```tsx
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`:
```tsx
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:
```tsx {% title="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:
```tsx {% title="packages/cms/types/src/cms.type.ts" %}
export type CmsType = 'wordpress' | 'keystatic' | 'my-cms';
```
### 2. Register the Client
Add your client to the registry:
```tsx {% title="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 client
cmsRegistry.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:
```tsx {% title="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:
```tsx {% title="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:
```tsx {% title="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
```bash
# .env
CMS_CLIENT=my-cms
MY_CMS_API_URL=https://api.my-cms.com
MY_CMS_API_KEY=your-api-key
```
## Real-World Examples
### Sanity
```tsx
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
```tsx
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:
```tsx {% title="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](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- Check the [Keystatic implementation](https://github.com/makerkit/next-supabase-saas-kit-turbo/tree/main/packages/cms/keystatic) for a complete example

321
docs/content/keystatic.mdoc Normal file
View File

@@ -0,0 +1,321 @@
---
status: "published"
title: "Keystatic CMS Setup for the Next.js Supabase SaaS Kit"
label: "Keystatic"
description: "Configure Keystatic as your CMS with local file storage for development or GitHub integration for production and team collaboration."
order: 2
---
Keystatic is a file-based CMS that stores content as Markdown/Markdoc files. It's the default CMS in Makerkit because it requires zero setup for local development and integrates with Git for version-controlled content.
## Storage Modes
Keystatic supports three storage modes:
| Mode | Storage | Best For | Edge Compatible |
|------|---------|----------|-----------------|
| `local` | Local filesystem | Development, solo projects | No |
| `github` | GitHub repository | Production, team collaboration | Yes |
| `cloud` | Keystatic Cloud | Managed hosting | Yes |
Local mode reads files directly from disk. GitHub mode fetches content via the GitHub API, making it compatible with edge runtimes like Cloudflare Workers.
## Local Storage (Default)
Local mode works out of the box. Content lives in your repository's `content/` directory:
```bash
# .env (optional - these are the defaults)
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=local
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
Content structure:
```
apps/web/content/
├── posts/ # Blog posts
├── documentation/ # Docs (supports nesting)
└── changelog/ # Release notes
```
**Limitations**: Local mode doesn't work with edge runtimes (Cloudflare Workers, Vercel Edge) because it requires filesystem access. Use GitHub mode for edge deployments.
## GitHub Storage
GitHub mode fetches content from your repository via the GitHub API. This enables edge deployment and team collaboration through Git.
### 1. Set Environment Variables
```bash
# .env
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github
NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO=your-org/your-repo
KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
### 2. Create a GitHub Token
1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
2. Create a new token with:
- **Repository access**: Select your content repository
- **Permissions**: Contents (Read-only for production, Read and write for admin UI)
3. Copy the token to `KEYSTATIC_GITHUB_TOKEN`
For read-only access (recommended for production):
```bash
KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
```
### 3. Configure Path Prefix
If your content isn't at the repository root, set the path prefix:
```bash
# For monorepos where content is in apps/web/content/
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
## Keystatic Cloud
Keystatic Cloud is a managed service that handles GitHub authentication and provides a hosted admin UI.
```bash
# .env
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=cloud
KEYSTATIC_STORAGE_PROJECT=your-project-id
```
Get your project ID from the [Keystatic Cloud dashboard](https://keystatic.cloud).
## Adding the Admin UI
Keystatic includes a visual editor for managing content. To add it:
```bash
turbo gen keystatic
```
This creates a route at `/keystatic` where you can create and edit content.
{% alert type="warning" title="Protect the admin in production" %}
By default, the Keystatic admin is only available in development. For production, add authentication:
```tsx {% title="app/keystatic/layout.tsx" %}
import { redirect } from 'next/navigation';
import { isSuperAdmin } from '@kit/admin';
export default async function KeystaticLayout({
children,
}: {
children: React.ReactNode;
}) {
const isAdmin = await isSuperAdmin();
if (!isAdmin) {
redirect('/');
}
return children;
}
```
{% /alert %}
### GitHub Mode Admin Setup
GitHub mode requires a GitHub App for the admin UI to authenticate and commit changes.
1. Install the Keystatic GitHub App on your repository
2. Follow the [Keystatic GitHub mode documentation](https://keystatic.com/docs/github-mode) for setup
The admin UI commits content changes directly to your repository, triggering your CI/CD pipeline.
## Default Collections
Makerkit configures three collections in `packages/cms/keystatic/src/keystatic.config.ts`:
### Posts
Blog posts with frontmatter:
```yaml
---
title: "Getting Started with Makerkit"
description: "A guide to building your SaaS"
publishedAt: 2025-01-15
status: published
categories:
- tutorials
tags:
- getting-started
image: /images/posts/getting-started.webp
---
Content here...
```
### Documentation
Hierarchical docs with ordering and collapsible sections:
```yaml
---
title: "Authentication"
label: "Auth" # Short label for navigation
description: "How authentication works"
order: 1
status: published
collapsible: true
collapsed: false
---
Content here...
```
Documentation supports nested directories. A file at `documentation/auth/sessions/sessions.mdoc` automatically becomes a child of `documentation/auth/auth.mdoc`.
### Changelog
Release notes:
```yaml
---
title: "v2.0.0 Release"
description: "Major update with new features"
publishedAt: 2025-01-10
status: published
---
Content here...
```
## Adding Custom Collections
Edit `packages/cms/keystatic/src/keystatic.config.ts` to add collections:
```tsx {% title="packages/cms/keystatic/src/keystatic.config.ts" %}
// In getKeystaticCollections()
return {
// ... existing collections
pages: collection({
label: 'Pages',
slugField: 'title',
path: `${path}pages/*`,
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
description: fields.text({ label: 'Description' }),
content: getContentField(),
status: fields.select({
defaultValue: 'draft',
label: 'Status',
options: statusOptions,
}),
},
}),
};
```
## Content Format
Keystatic uses [Markdoc](https://markdoc.dev), a Markdown superset with custom components.
### Basic Markdown
Standard Markdown syntax works:
```markdown
# Heading
Paragraph with **bold** and *italic*.
- List item
- Another item
```code
Code block
```
```
### Images
Images are stored in `public/site/images/` and referenced with the public path:
```markdown
![Alt text](/site/images/screenshot.webp)
```
### Custom Components
Makerkit extends Markdoc with custom nodes. Check `packages/cms/keystatic/src/markdoc-nodes.ts` for available components.
## Cloudflare Workers Compatibility
Cloudflare Workers don't send the `User-Agent` header, which the GitHub API requires. Add this workaround to `packages/cms/keystatic/src/keystatic-client.ts`:
```tsx {% title="packages/cms/keystatic/src/keystatic-client.ts" %}
// Add at the top of the file
const self = global || globalThis || this;
const originalFetch = self.fetch;
self.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const requestInit: RequestInit = {
...(init ?? {}),
headers: {
...(init?.headers ?? {}),
'User-Agent': 'Cloudflare-Workers',
}
};
return originalFetch(input, requestInit);
};
```
## Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CMS_CLIENT` | No | `keystatic` | CMS provider |
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND` | No | `local` | Storage mode: `local`, `github`, `cloud` |
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` | GitHub only | - | Repository in `owner/repo` format |
| `KEYSTATIC_GITHUB_TOKEN` | GitHub only | - | GitHub personal access token |
| `KEYSTATIC_STORAGE_PROJECT` | Cloud only | - | Keystatic Cloud project ID |
| `KEYSTATIC_PATH_PREFIX` | No | - | Path to content in monorepos |
| `NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH` | No | `./content` | Content directory path |
| `KEYSTATIC_STORAGE_BRANCH_PREFIX` | No | - | Branch prefix for GitHub mode |
## Troubleshooting
### Content not loading in production
Verify GitHub mode is configured:
- `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github`
- `KEYSTATIC_GITHUB_TOKEN` has read access to the repository
- `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` matches your repository
### Admin UI shows authentication error
For GitHub mode, ensure:
- The Keystatic GitHub App is installed on your repository
- Your GitHub token has write permissions (for the admin)
### Edge runtime errors
Local mode doesn't work on edge. Switch to GitHub or Cloud mode:
- Set `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github`
- Configure GitHub token with read access
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Learn the full API for fetching content
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- [Keystatic Documentation](https://keystatic.com/docs): Official Keystatic docs

423
docs/content/supabase.mdoc Normal file
View File

@@ -0,0 +1,423 @@
---
status: "published"
title: "Supabase CMS Plugin for the Next.js Supabase SaaS Kit"
label: "Supabase"
description: "Store content in your Supabase database with optional Supamode integration for a visual admin interface."
order: 4
---
The Supabase CMS plugin stores content directly in your Supabase database. This gives you full control over your content schema, row-level security policies, and the ability to query content alongside your application data.
This approach works well when you want content in the same database as your app, need RLS policies on content, or want to use [Supamode](/supabase-cms) as your admin interface.
## Installation
### 1. Install the Plugin
Run the Makerkit CLI from your app directory:
```bash
npx @makerkit/cli plugins install
```
Select **Supabase CMS** when prompted.
### 2. Add the Package Dependency
Add the plugin to your CMS package:
```bash
pnpm --filter "@kit/cms" add "@kit/supabase-cms@workspace:*"
```
### 3. Register the CMS Type
Update the CMS type definition to include Supabase:
```tsx {% title="packages/cms/types/src/cms.type.ts" %}
export type CmsType = 'wordpress' | 'keystatic' | 'supabase';
```
### 4. Register the Client
Add the Supabase client to the CMS registry:
```tsx {% title="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();
});
// Add Supabase registration
cmsRegistry.register('supabase', async () => {
const { createSupabaseCmsClient } = await import('@kit/supabase-cms');
return createSupabaseCmsClient();
});
export async function createCmsClient(type: CmsType = CMS_CLIENT) {
return cmsRegistry.get(type);
}
```
### 5. Register the Content Renderer
Add the Supabase content renderer:
```tsx {% title="packages/cms/core/src/content-renderer.tsx" %}
cmsContentRendererRegistry.register('supabase', async () => {
return function SupabaseContentRenderer({ content }: { content: unknown }) {
return content as React.ReactNode;
};
});
```
The default renderer returns content as-is. If you store HTML, it renders as HTML. For Markdown, add a Markdown renderer.
### 6. Run the Migration
Create a new migration file:
```bash
pnpm --filter web supabase migration new cms
```
Copy the contents of `packages/plugins/supabase-cms/migration.sql` to the new migration file, then apply it:
```bash
pnpm --filter web supabase migration up
```
### 7. Generate Types
Regenerate TypeScript types to include the new tables:
```bash
pnpm run supabase:web:typegen
```
### 8. Set the Environment Variable
Switch to the Supabase CMS:
```bash
# apps/web/.env
CMS_CLIENT=supabase
```
## Database Schema
The plugin creates three tables:
### content_items
Stores all content (posts, pages, docs):
```sql
create table public.content_items (
id uuid primary key default gen_random_uuid(),
title text not null,
slug text not null unique,
description text,
content text,
image text,
status text not null default 'draft',
collection text not null default 'posts',
published_at timestamp with time zone,
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now(),
parent_id uuid references public.content_items(id),
"order" integer default 0,
language text,
metadata jsonb default '{}'::jsonb
);
```
### categories
Content categories:
```sql
create table public.categories (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamp with time zone default now()
);
```
### tags
Content tags:
```sql
create table public.tags (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamp with time zone default now()
);
```
### Junction Tables
Many-to-many relationships:
```sql
create table public.content_items_categories (
content_item_id uuid references public.content_items(id) on delete cascade,
category_id uuid references public.categories(id) on delete cascade,
primary key (content_item_id, category_id)
);
create table public.content_items_tags (
content_item_id uuid references public.content_items(id) on delete cascade,
tag_id uuid references public.tags(id) on delete cascade,
primary key (content_item_id, tag_id)
);
```
## Using Supamode as Admin
{% img src="/assets/images/supamode-cms-plugin-posts.webp" width="2970" height="2028" alt="Supamode CMS Posts Interface" /%}
[Supamode](/supabase-cms) provides a visual interface for managing content in Supabase tables. It's built specifically for Supabase and integrates with RLS policies.
{% alert type="default" title="Supamode is optional" %}
Supamode is a separate product. You can use any Postgres admin tool, build your own admin, or manage content via SQL.
{% /alert %}
### Setting Up Supamode
1. Install Supamode following the [installation guide](/docs/supamode/installation)
2. Sync the CMS tables to Supamode:
- Run the following SQL commands in Supabase Studio's SQL Editor:
```sql
-- Run in Supabase Studio's SQL Editor
select supamode.sync_managed_tables('public', 'content_items');
select supamode.sync_managed_tables('public', 'categories');
select supamode.sync_managed_tables('public', 'tags');
```
3. Configure table views in the Supamode UI under **Resources**
### Content Editing
With Supamode, you can:
- Create and edit content with a form-based UI
- Upload images to Supabase Storage
- Manage categories and tags
- Preview content before publishing
- Filter and search content
## Querying Content
The Supabase CMS client implements the standard CMS interface:
```tsx
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
// Get all published posts
const { items, total } = await client.getContentItems({
collection: 'posts',
status: 'published',
limit: 10,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
// Get a specific post
const post = await client.getContentItemBySlug({
slug: 'getting-started',
collection: 'posts',
});
```
### Direct Supabase Queries
For complex queries, use the Supabase client directly:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function getPostsWithCustomQuery() {
const client = getSupabaseServerClient();
const { data, error } = await client
.from('content_items')
.select(`
*,
categories:content_items_categories(
category:categories(*)
),
tags:content_items_tags(
tag:tags(*)
)
`)
.eq('collection', 'posts')
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(10);
return data;
}
```
## Row-Level Security
Add RLS policies to control content access:
```sql
-- Allow public read access to published content
create policy "Public can read published content"
on public.content_items
for select
using (status = 'published');
-- Allow authenticated users to read all content
create policy "Authenticated users can read all content"
on public.content_items
for select
to authenticated
using (true);
-- Allow admins to manage content
create policy "Admins can manage content"
on public.content_items
for all
to authenticated
using (
exists (
select 1 from public.accounts
where accounts.id = auth.uid()
and accounts.is_admin = true
)
);
```
## Content Format
The `content` field stores text. Common formats:
### HTML
Store rendered HTML directly:
```tsx
const post = {
title: 'Hello World',
content: '<p>This is <strong>HTML</strong> content.</p>',
};
```
Render with `dangerouslySetInnerHTML` or a sanitizing library.
### Markdown
Store Markdown and render at runtime:
```tsx
import { marked } from 'marked';
import type { Cms } from '@kit/cms-types';
function renderContent(markdown: string) {
return { __html: marked(markdown) };
}
function Post({ post }: { post: Cms.ContentItem }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={renderContent(post.content as string)} />
</article>
);
}
```
### JSON
Store structured content as JSON in the `metadata` field:
```tsx
const post = {
title: 'Product Comparison',
content: '', // Optional summary
metadata: {
products: [
{ name: 'Basic', price: 9 },
{ name: 'Pro', price: 29 },
],
},
};
```
## Customizing the Schema
Extend the schema by modifying the migration:
```sql
-- Add custom fields
alter table public.content_items
add column author_id uuid references auth.users(id),
add column reading_time integer,
add column featured boolean default false;
-- Add indexes
create index content_items_featured_idx
on public.content_items(featured)
where status = 'published';
```
Update the Supabase client to handle custom fields.
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `CMS_CLIENT` | Yes | Set to `supabase` |
The plugin uses your existing Supabase connection (no additional configuration needed).
## Troubleshooting
### Migration fails
Check that you have the latest Supabase CLI and your local database is running:
```bash
pnpm --filter web supabase start
pnpm --filter web supabase migration up
```
### TypeScript errors after migration
Regenerate types:
```bash
pnpm run supabase:web:typegen
```
### Content not appearing
Verify:
- The `status` field is set to `published`
- The `collection` field matches your query
- RLS policies allow access
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [Supamode Documentation](/docs/supamode/installation): Set up the admin interface
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers

293
docs/content/wordpress.mdoc Normal file
View File

@@ -0,0 +1,293 @@
---
status: "published"
title: "WordPress CMS Integration for the Next.js Supabase SaaS Kit"
label: "WordPress"
description: "Connect your WordPress site to Makerkit using the REST API for blog posts, documentation, and dynamic pages."
order: 3
---
WordPress integration lets you use an existing WordPress site as your content backend. Makerkit fetches content through the WordPress REST API, so you get the familiar WordPress admin while serving content from your Next.js app.
This approach works well when you have a content team that knows WordPress, or you want to leverage WordPress plugins for SEO, forms, or other features.
## Quick Setup
### 1. Set Environment Variables
```bash
# .env
CMS_CLIENT=wordpress
WORDPRESS_API_URL=https://your-wordpress-site.com
```
### 2. Configure WordPress Permalinks
WordPress REST API requires pretty permalinks. In your WordPress admin:
1. Go to Settings → Permalinks
2. Select "Post name" (`/%postname%/`) or any option except "Plain"
3. Save changes
Without this, the REST API won't resolve slugs correctly.
## Content Mapping
WordPress content types map to Makerkit collections:
| WordPress Type | Makerkit Collection | Notes |
|----------------|---------------------|-------|
| Posts | `posts` | Standard WordPress posts |
| Pages | `pages` | WordPress pages |
### Blog Posts
Create posts in WordPress with:
- **Category**: Add a category named `blog` for blog posts
- **Tags**: Use tags for filtering (including language codes like `en`, `de`)
- **Featured Image**: Automatically used as the post image
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
categories: ['blog'],
limit: 10,
});
```
### Documentation Pages
WordPress doesn't natively support hierarchical documentation. To build docs:
1. Create pages (not posts) for documentation
2. Enable categories for pages (see below)
3. Add a category named `documentation`
#### Enabling Categories for Pages
Add this to your theme's `functions.php`:
```php {% title="wp-content/themes/your-theme/functions.php" %}
function add_categories_to_pages() {
register_taxonomy_for_object_type('category', 'page');
}
add_action('init', 'add_categories_to_pages');
```
Then fetch documentation:
```tsx
const { items } = await client.getContentItems({
collection: 'pages',
categories: ['documentation'],
});
```
## Multi-Language Content
WordPress doesn't have built-in multi-language support. Makerkit uses tags for language filtering:
1. Create tags for each language: `en`, `de`, `fr`, etc.
2. Add the appropriate language tag to each post
3. Filter by language in your queries:
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
language: 'en', // Filters by tag
});
```
For full multi-language support, consider plugins like WPML or Polylang, then adapt the Makerkit WordPress client to use their APIs.
## Local Development
Makerkit includes a Docker Compose setup for local WordPress development:
```bash
# From packages/cms/wordpress/
docker-compose up
```
Or from the root:
```bash
pnpm --filter @kit/wordpress run start
```
This starts WordPress at `http://localhost:8080`.
### Default Credentials
```
Database Host: db
Database Name: wordpress
Database User: wordpress
Database Password: wordpress
```
On first visit, WordPress prompts you to complete the installation.
## Production Configuration
### WordPress Hosting
Host WordPress anywhere that exposes the REST API:
- WordPress.com (Business plan or higher)
- Self-hosted WordPress
- Managed WordPress hosting (WP Engine, Kinsta, etc.)
### CORS Configuration
If your Next.js app and WordPress are on different domains, configure CORS in WordPress.
Add to `wp-config.php`:
```php {% title="wp-config.php" %}
header("Access-Control-Allow-Origin: https://your-nextjs-app.com");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
```
Or use a plugin like "WP CORS" for more control.
### Caching
The WordPress REST API can be slow. Consider:
1. **WordPress caching plugins**: WP Super Cache, W3 Total Cache
2. **CDN for the API**: Cloudflare, Fastly
3. **Next.js caching**: Use `unstable_cache` or ISR
```tsx
import { unstable_cache } from 'next/cache';
import { createCmsClient } from '@kit/cms';
const getCachedPosts = unstable_cache(
async () => {
const client = await createCmsClient();
return client.getContentItems({ collection: 'posts', limit: 10 });
},
['wordpress-posts'],
{ revalidate: 300 } // 5 minutes
);
```
## Content Structure
### Post Fields
WordPress posts return these fields through the Makerkit CMS interface:
| Field | Source | Notes |
|-------|--------|-------|
| `title` | `title.rendered` | HTML-decoded |
| `content` | `content.rendered` | Full HTML content |
| `description` | `excerpt.rendered` | Post excerpt |
| `image` | Featured media | Full URL |
| `slug` | `slug` | URL slug |
| `publishedAt` | `date` | ISO 8601 format |
| `status` | `status` | Mapped to Makerkit statuses |
| `categories` | Category taxonomy | Array of category objects |
| `tags` | Tag taxonomy | Array of tag objects |
| `order` | `menu_order` | For page ordering |
| `parentId` | `parent` | For hierarchical pages |
### Status Mapping
| WordPress Status | Makerkit Status |
|------------------|-----------------|
| `publish` | `published` |
| `draft` | `draft` |
| `pending` | `pending` |
| Other | `draft` |
## Rendering WordPress Content
WordPress content is HTML. Use the `ContentRenderer` component:
```tsx
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function BlogPost({ slug }: { slug: string }) {
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{/* ContentRenderer handles HTML safely */}
<ContentRenderer content={post.content} />
</article>
);
}
```
The WordPress renderer sanitizes HTML and applies appropriate styling.
### Custom Styling
WordPress content includes CSS classes. Add styles to your global CSS:
```css {% title="apps/web/styles/globals.css" %}
/* WordPress block styles */
.wp-block-image {
margin: 2rem 0;
}
.wp-block-quote {
border-left: 4px solid var(--primary);
padding-left: 1rem;
font-style: italic;
}
/* Gutenberg alignment */
.alignwide {
max-width: 100vw;
margin-left: calc(-50vw + 50%);
margin-right: calc(-50vw + 50%);
}
```
## Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `CMS_CLIENT` | Yes | Set to `wordpress` |
| `WORDPRESS_API_URL` | Yes | WordPress site URL (no trailing slash) |
## Troubleshooting
### REST API returns 404
- Verify permalinks are set to something other than "Plain"
- Check that the REST API is accessible: `curl https://your-site.com/wp-json/wp/v2/posts`
- Some security plugins disable the REST API; check your plugins
### Categories not working for pages
Ensure you've added the `add_categories_to_pages()` function to your theme's `functions.php`.
### Images not loading
- Check that `WORDPRESS_API_URL` matches the site URL in WordPress settings
- Verify featured images are set on posts
- Check for mixed content issues (HTTP vs HTTPS)
### CORS errors
Add CORS headers to WordPress (see Production Configuration above) or use a proxy.
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build custom integrations