Supabase CMS Plugin for the Next.js Supabase SaaS Kit

Store content in your Supabase database with optional Supamode integration for a visual admin interface.

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 as your admin interface.

Installation

1. Install the Plugin

Run the Makerkit CLI from your app directory:

npx @makerkit/cli plugins install

Select Supabase CMS when prompted.

2. Add the Package Dependency

Add the plugin to your CMS package:

pnpm --filter "@kit/cms" add "@kit/supabase-cms@workspace:*"

3. Register the CMS Type

Update the CMS type definition to include Supabase:

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:

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:

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:

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:

pnpm --filter web supabase migration up

7. Generate Types

Regenerate TypeScript types to include the new tables:

pnpm run supabase:web:typegen

8. Set the Environment Variable

Switch to the Supabase CMS:

# apps/web/.env
CMS_CLIENT=supabase

Database Schema

The plugin creates three tables:

content_items

Stores all content (posts, pages, docs):

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:

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:

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:

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

Supamode CMS Posts Interface

Supamode provides a visual interface for managing content in Supabase tables. It's built specifically for Supabase and integrates with RLS policies.

Setting Up Supamode

  1. Install Supamode following the installation guide
  2. Sync the CMS tables to Supamode:
    • Run the following SQL commands in Supabase Studio's SQL Editor:
      -- 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:

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:

import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function getPostsWithCustomQuery() {
const client = await 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:

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

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:

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:

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:

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

VariableRequiredDescription
CMS_CLIENTYesSet 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:

pnpm --filter web supabase start
pnpm --filter web supabase migration up

TypeScript errors after migration

Regenerate types:

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