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 installSelect 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 registrationcmsRegistry.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 cmsCopy the contents of packages/plugins/supabase-cms/migration.sql to the new migration file, then apply it:
pnpm --filter web supabase migration up7. Generate Types
Regenerate TypeScript types to include the new tables:
pnpm run supabase:web:typegen8. Set the Environment Variable
Switch to the Supabase CMS:
# apps/web/.envCMS_CLIENT=supabaseDatabase 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 provides a visual interface for managing content in Supabase tables. It's built specifically for Supabase and integrates with RLS policies.
Supamode is optional
Supamode is a separate product. You can use any Postgres admin tool, build your own admin, or manage content via SQL.
Setting Up Supamode
- Install Supamode following the installation guide
- Sync the CMS tables to Supamode:
- Run the following SQL commands in Supabase Studio's SQL Editor:-- Run in Supabase Studio's SQL Editorselect supamode.sync_managed_tables('public', 'content_items');select supamode.sync_managed_tables('public', 'categories');select supamode.sync_managed_tables('public', 'tags');
- Run the following SQL commands in Supabase Studio's SQL Editor:
- 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 postsconst { items, total } = await client.getContentItems({ collection: 'posts', status: 'published', limit: 10, sortBy: 'publishedAt', sortDirection: 'desc',});// Get a specific postconst 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 contentcreate policy "Public can read published content" on public.content_items for select using (status = 'published');-- Allow authenticated users to read all contentcreate policy "Authenticated users can read all content" on public.content_items for select to authenticated using (true);-- Allow admins to manage contentcreate 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 fieldsalter 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 indexescreate 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:
pnpm --filter web supabase startpnpm --filter web supabase migration upTypeScript errors after migration
Regenerate types:
pnpm run supabase:web:typegenContent not appearing
Verify:
- The
statusfield is set topublished - The
collectionfield matches your query - RLS policies allow access
Next Steps
- CMS API Reference: Full API documentation
- Supamode Documentation: Set up the admin interface
- CMS Overview: Compare CMS providers