A Supabase CMS stores your content (blog posts, documentation, changelogs) directly in Postgres and exposes it through Supabase's API layer. Instead of managing Markdown files in Git, your team writes and publishes from a visual editor while your Next.js app reads content from the database at build or request time.
This guide shows how to build a Supabase CMS integration for Makerkit using Supamode as the content editor. Makerkit ships with Keystatic as the default CMS provider, but its plugin system lets you swap to any backend, including Supabase, with zero frontend changes. You'll need a working Makerkit Next.js Supabase Turbo project, Docker for local Supabase, and a copy of Supamode installed.
Why Use Supabase as a CMS?
Most headless CMS platforms (Contentful, Sanity, Strapi) store your content on someone else's infrastructure. With a Supabase CMS, your content lives in the same Postgres database as the rest of your app data. That means:
- One infrastructure bill, not two. No separate CMS hosting or API limits.
- Same auth system. Supabase Auth + RLS protect content the same way they protect user data.
- SQL access to content. Query posts by category, join with user data, run analytics, all in plain SQL.
- Full ownership. No vendor lock-in. Your content is rows in Postgres, exportable with
pg_dump.
The tradeoff is you need a content editing interface. That's where Supamode comes in: it reads your Postgres schema and generates a rich admin panel with Markdown editing, RBAC, and audit logging.
How Makerkit's CMS Plugin System Works
Makerkit uses a registry pattern to decouple content fetching from the content source. The @kit/cms package exposes a createCmsClient() factory that returns whichever CMS implementation matches the CMS_CLIENT environment variable:
// packages/cms/core/src/create-cms-client.ts (simplified)const cmsRegistry = createRegistry<CmsClient, CmsType>();cmsRegistry.register('keystatic', async () => { const { createKeystaticClient } = await import('@kit/keystatic'); return createKeystaticClient();});export async function createCmsClient(type: CmsType = CMS_CLIENT) { return cmsRegistry.get(type);}Every CMS provider implements the CmsClient abstract class from @kit/cms-types, providing six methods: getContentItems, getContentItemBySlug, getCategories, getCategoryBySlug, getTags, and getTagBySlug.
The blog, docs, and changelog pages call createCmsClient() and work against the abstract interface. Swapping providers means writing a new CmsClient subclass, registering it, and changing one env var. No frontend code touches.
Building the Supabase CMS Plugin for Makerkit
We'll create a CMS plugin that stores content in Postgres and reads it through Supabase's API. The plugin implements Makerkit's CmsClient interface, so your existing blog, docs, and changelog pages work without changes.
Supabase CMS Database Schema
Create a new migration from the root of your Makerkit project:
pnpm --filter web supabase migration new supabase-cms-schemaPaste the following SQL into the generated migration file. This creates a unified content_items table that handles blog posts, documentation, and changelogs through a collection column, plus supporting tables for categories and tags with many-to-many relationships.
-- Content item status enumcreate type public.content_status as enum ( 'draft', 'published', 'archived');-- Categories tablecreate table if not exists public.categories ( id uuid primary key default gen_random_uuid(), slug text not null unique, name text not null unique, description text, color text default '#000000', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), check (length(trim(slug)) > 0), check (length(trim(name)) > 0));alter table public.categories enable row level security;-- Tags tablecreate table if not exists public.tags ( id uuid primary key default gen_random_uuid(), slug text not null unique, name text not null unique, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), check (length(trim(slug)) > 0), check (length(trim(name)) > 0));alter table public.tags enable row level security;-- Content items table (unified for posts, docs, changelog)create table if not exists public.content_items ( id uuid primary key default gen_random_uuid(), slug text not null, collection text not null, title text not null, description text, content text not null, status public.content_status not null default 'draft', published_at timestamptz, image_url text, author_id uuid references auth.users (id) on delete set null, category_id uuid references public.categories (id) on delete set null, parent_id uuid references public.content_items (id) on delete cascade, "order" integer not null default 0, featured boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (collection, slug), check (length(trim(slug)) > 0), check (length(trim(title)) > 0), check ( (status = 'published' and published_at is not null) or (status in ('draft', 'archived')) ));alter table public.content_items enable row level security;-- Auto-set published_at when status changes to 'published'create or replace function public.content_update_published_at() returns trigger as $$begin new.published_at = now(); return new;end;$$ language plpgsql security definer;create trigger content_update_published_at before update on public.content_items for each row when (new.status = 'published') execute function public.content_update_published_at();-- Auto-update updated_at on every editcreate or replace function public.content_update_timestamp() returns trigger as $$begin new.updated_at = now(); return new;end;$$ language plpgsql security definer;create trigger content_update_timestamp before update on public.content_items for each row execute function public.content_update_timestamp();-- Many-to-many: content <-> categoriescreate table if not exists public.content_categories ( content_item_id uuid not null references public.content_items (id) on delete cascade, category_id uuid not null references public.categories (id) on delete cascade, primary key (content_item_id, category_id));alter table public.content_categories enable row level security;-- Many-to-many: content <-> tagscreate table if not exists public.content_tags ( content_item_id uuid not null references public.content_items (id) on delete cascade, tag_id uuid not null references public.tags (id) on delete cascade, primary key (content_item_id, tag_id));alter table public.content_tags enable row level security;-- Performance indexcreate index if not exists idx_content_items_collection_status_published on public.content_items (collection, status, published_at desc);Design notes:
- Unified
content_itemstable instead of separateposts,docs,changelogtables. Thecollectioncolumn distinguishes them. This maps directly to Makerkit's CMS abstraction wheregetContentItems({ collection: 'posts' })filters by collection. - Compound unique constraint on
(collection, slug)so the same slug can exist in different collections (e.g./blog/getting-startedand/docs/getting-started). - Check constraint on
published_atforces published content to always have a timestamp. The trigger auto-fills it when an item transitions topublished, so editors never set it by hand. - RLS enabled, no policies. RLS is on every table so non-admin access is blocked by default. Makerkit blocks the
anonrole, so public read policies wouldn't help anyway. We use the admin client (getSupabaseServerAdminClient) server-side to fetch content, and Supamode handles write access through its own RBAC system.
Apply the Migration
pnpm --filter web supabase migrations upThen regenerate the TypeScript types so the Supabase client knows about the new tables:
pnpm supabase:web:typegenBuilding the Supabase CMS Client
Create a new CMS package for the Supabase provider. First, set up the package structure:
pnpm turbo gen packageIn the next step, you will be asked to enter the name of the package. Enter cms-supabase and press enter.
Run the following command to install the package:
pnpm add --filter "@kit/cms-supabase" react "@kit/cms-types@workspace:*" "@kit/supabase@workspace:*" @markdoc/markdoc "@types/react"Update the file at packages/cms-supabase/src/index.ts with the following content:
packages/cms-supabase/src/index.ts
import 'server-only';import React from 'react';import { type Cms, CmsClient } from '@kit/cms-types';import type { Tables } from '@kit/supabase/database';import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';type ContentItemRow = Tables<'content_items'>;type CategoryRow = Tables<'categories'>;type TagRow = Tables<'tags'>;type ContentItemWithRelations = ContentItemRow & { categories?: CategoryRow[]; tags?: TagRow[]; author?: { id: string; email?: string; raw_user_meta_data?: Record<string, unknown>; };};export async function createSupabaseCmsClient() { return new SupabaseCmsClient();}class SupabaseCmsClient extends CmsClient { async getContentItems( options?: Cms.GetContentItemsOptions, ): Promise<{ total: number; items: Cms.ContentItem[] }> { const client = getSupabaseServerAdminClient(); const { collection, limit = 100, offset = 0, status = 'published', sortBy = 'publishedAt', sortDirection = 'desc', categories = [], content: _content = false, } = options ?? {}; // Build base query let query = client.from('content_items').select( ` *, categories:content_categories(category:categories(*)), tags:content_tags(tag:tags(*)) `, { count: 'exact' }, ); // Apply filters if (collection) { query = query.eq('collection', collection); } // Only filter by status if it's a valid database status const validStatuses = ['draft', 'published', 'archived'] as const; type ValidStatus = (typeof validStatuses)[number]; if (status && validStatuses.includes(status as ValidStatus)) { query = query.eq('status', status as ValidStatus); } if (categories.length > 0) { query = query.in('category_id', categories); } // Sorting const sortColumn = sortBy === 'publishedAt' ? 'published_at' : sortBy; query = query.order(sortColumn, { ascending: sortDirection === 'asc' }); // Pagination if (limit !== Infinity) { query = query.range(offset, offset + limit - 1); } const { data, error, count } = await query; if (error) { throw error; } const items = await Promise.all( (data ?? []).map(async (row) => { // Flatten the nested categories/tags structure const flatRow = { ...row, categories: row.categories?.map((c) => c.category) ?? [], tags: row.tags?.map((t) => t.tag) ?? [], }; // Render markdown content if requested const shouldFetchContent = _content ?? true; const renderedContent = shouldFetchContent ? await renderMarkdoc(flatRow.content) : flatRow.content; return mapContentItemToDto({ ...flatRow, content: renderedContent as string, }); }), ); return { total: count ?? 0, items, }; } async getContentItemBySlug(params: { slug: string; collection: string; status?: Cms.ContentItemStatus; content?: boolean; }): Promise<Cms.ContentItem | undefined> { const client = getSupabaseServerAdminClient(); let query = client .from('content_items') .select( ` *, categories:content_categories(category:categories(*)), tags:content_tags(tag:tags(*)) `, ) .eq('slug', params.slug) .eq('collection', params.collection); // Only filter by status if it's a valid database status const validStatuses = ['draft', 'published', 'archived'] as const; type ValidStatus = (typeof validStatuses)[number]; if (params.status && validStatuses.includes(params.status as ValidStatus)) { query = query.eq('status', params.status as ValidStatus); } const { data, error } = await query.maybeSingle(); if (error) { throw error; } if (!data) { return undefined; } // Flatten nested structure const flatRow = { ...data, categories: data.categories?.map((c) => c.category) ?? [], tags: data.tags?.map((t) => t.tag) ?? [], }; // Render markdown content if requested const shouldFetchContent = params.content ?? true; const renderedContent = shouldFetchContent ? await renderMarkdoc(flatRow.content) : flatRow.content; return mapContentItemToDto({ ...flatRow, content: renderedContent as string, }); } async getCategories( options?: Cms.GetCategoriesOptions, ): Promise<Cms.Category[]> { const client = getSupabaseServerAdminClient(); const { limit = 100, offset = 0, slugs = [] } = options ?? {}; let query = client.from('categories').select('*'); if (slugs.length > 0) { query = query.in('slug', slugs); } query = query.range(offset, offset + limit - 1).order('name'); const { data, error } = await query; if (error) { throw error; } return (data ?? []).map(mapCategoryToDto); } async getCategoryBySlug(slug: string): Promise<Cms.Category | undefined> { const client = getSupabaseServerAdminClient(); const { data, error } = await client .from('categories') .select('*') .eq('slug', slug) .maybeSingle(); if (error) { throw error; } return data ? mapCategoryToDto(data) : undefined; } async getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]> { const client = getSupabaseServerAdminClient(); const { limit = 100, offset = 0, slugs = [] } = options ?? {}; let query = client.from('tags').select('*'); if (slugs.length > 0) { query = query.in('slug', slugs); } query = query.range(offset, offset + limit - 1).order('name'); const { data, error } = await query; if (error) { throw error; } return (data ?? []).map(mapTagToDto); } async getTagBySlug(slug: string): Promise<Cms.Tag | undefined> { const client = getSupabaseServerAdminClient(); const { data, error } = await client .from('tags') .select('*') .eq('slug', slug) .maybeSingle(); if (error) { throw error; } return data ? mapTagToDto(data) : undefined; }}async function renderMarkdoc(markdownContent: string) { const Markdoc = await import('@markdoc/markdoc'); // Parse the markdown string into an AST const ast = Markdoc.parse(markdownContent); // Transform the AST into a renderable tree const content = Markdoc.transform(ast); // Render to React return Markdoc.renderers.react(content, React);}function mapContentItemToDto(row: ContentItemWithRelations): Cms.ContentItem { return { id: row.id, slug: row.slug, title: row.title, label: undefined, description: row.description ?? undefined, content: row.content, publishedAt: row.published_at ?? new Date().toISOString(), image: row.image_url ?? undefined, status: row.status as Cms.ContentItemStatus, url: buildContentUrl(row.collection, row.slug), categories: (row.categories ?? []).map(mapCategoryToDto), tags: (row.tags ?? []).map(mapTagToDto), order: row.order, children: [], parentId: row.parent_id ?? undefined, collapsible: undefined, collapsed: undefined, };}function mapCategoryToDto(row: CategoryRow): Cms.Category { return { id: row.id, slug: row.slug, name: row.name, };}function mapTagToDto(row: TagRow): Cms.Tag { return { id: row.id, slug: row.slug, name: row.name, };}function buildContentUrl(collection: string, slug: string): string { switch (collection) { case 'posts': return `/blog/${slug}`; case 'documentation': return `/docs/${slug}`; case 'changelog': return `/changelog/${slug}`; default: return `/${collection}/${slug}`; }}Implementation notes:
- We use the admin client (
getSupabaseServerAdminClient) because Makerkit blocks theanonrole, and CMS reads happen server-side. The admin client bypasses RLS entirely, which is fine here since it only runs on the server (enforced by theserver-onlyimport). Thestatusfilter in the query controls what content is visible. - The junction table queries use Supabase's nested select syntax (
content_categories(category:categories(*))) to fetch related categories and tags in a single query rather than N+1 queries. - Content rendering uses Markdoc. When
content: trueis passed, the raw Markdown stored in Postgres gets parsed and rendered to React elements. Whencontent: false(the default for list views), we skip rendering to keep list queries fast.
The buildContentUrl function maps collections to the URL paths that Makerkit's marketing routes expect.
Wiring It Into Makerkit
Now wire the new client into Makerkit's CMS registry. You need to update two files.
Add the Type
In packages/cms/types/src/cms.type.ts, add 'supabase' to the union:
packages/cms/types/src/cms.type.ts
export type CmsType = 'wordpress' | 'keystatic' | 'supabase';Install the CMS Supabase Package into the CMS Core Package
Run the following command to install the package:
pnpm add --filter "@kit/cms" "@kit/cms-supabase@workspace:*"Now, we can register the Supabase client in the CMS core package.
Register the Factory
In packages/cms/core/src/create-cms-client.ts, register the Supabase client:
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>();cmsRegistry.register('keystatic', async () => { const { createKeystaticClient } = await import('@kit/keystatic'); return createKeystaticClient();});// Register the Supabase CMS providercmsRegistry.register('supabase', async () => { const { createSupabaseCmsClient } = await import( '@kit/cms-supabase' ); return createSupabaseCmsClient();});export async function createCmsClient( type: CmsType = CMS_CLIENT,) { return cmsRegistry.get(type);}Register the Content Renderer
In packages/cms/core/src/content-renderer.tsx, add a renderer for Supabase content:
packages/cms/core/src/content-renderer.tsx
cmsContentRendererRegistry.register('supabase', async () => { // Supabase content is pre-rendered by the client, // so the renderer just passes it through return ({ content }: { content: unknown }) => content as React.ReactNode;});Since SupabaseCmsClient already renders Markdoc to React elements when content: true is set, the content renderer is a passthrough.
Switch the Environment Variable
In your .env file:
CMS_CLIENT=supabaseYour blog, docs, and changelog pages now pull content from Supabase instead of Keystatic files. Rebuild and verify:
pnpm devVisit /blog, the page will be empty since the database has no content yet.
Note: Supamode is just one of the many options if you choose this route, as the content essentially lives in your Postgres database. Any other CMS that can connect to Supabase and read from the content_items table will work. You are not locked into using Supamode.
We built Supamode as the best CMS for Supabase because we wanted a content editor that keeps data in Postgres, runs self-hosted, and doesn't charge per seat. If that matters to your team, read on.
Setting Up Supamode as Your Supabase CMS Editor
Supamode is a self-hosted admin panel purpose-built as a CMS for Supabase. It reads your Postgres schema and generates a visual editing interface with rich Markdown editing, relationship management, RBAC, and audit logging.
If you don't have Supamode installed yet, follow the installation guide.
Install the Supamode Schema
Supamode creates its own supamode schema in your database - so we need to create a migration for it in your Makerkit project.
If you've already generated the Supamode schema, you can skip this step.
First, find the Auth ID of the user who will be the root admin. You can find it in Supabase Studio under Authentication > Users, or query it:
select id from auth.users where email = 'your-email@example.com';Generate the Supamode seed from the Supamode project root:
pnpm run generate-schema \ --template saas \ --root-account YOUR_AUTH_USER_IDCreate a migration in your Makerkit project for the Supamode schema:
pnpm --filter web supabase migration new supamode-schemaCopy all Supamode migration SQL and the generated seed SQL into this migration file AND also include the other migrations in the Supamode project:
pnpm --filter web supabase migrations upPoint to the Supabase Instance in Supamode
By default, Supamode points to its own testing instance. We need to point it to the actual Supabase instance (from the Supamode project):
apps/app/.env
VITE_SUPABASE_URL=http://localhost:54321In the API project, switch these variables to the actual Supabase instance (from the Supamode project)
apps/api/.env
SUPABASE_URL=http://localhost:54321SUPABASE_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgresRun the Supamode Project
Run the following command to start the Supamode project from the Supamode project root:
pnpm devThis will start the Supamode project on http://localhost:5173 and the API at port 3000. Since the Next.js project is running on port 3000, we may also need to change the port in the Next.js project to 3001.
pnpm --filter web dev --port 3001Your Next.js project should now be running on http://localhost:3001.
Signing into Supamode
Sign into Supamode with the root account registered when we ran the generate-schema command to generate the Supamode schema. This account has full access and permissions to the Supabase project.
Sync Your Content Tables
Once you've signed in, you can start using the Supamode CMS. The first thing you want to do is to sync your content tables to Supamode.
You can do so from Settings > Resources and clicking the Sync Tables button. Use the public schema for now.
Click to expandAfter clicking the Sync Tables button, you should see the content_items, categories, and tags tables in the Supamode CMS - and the rest of the tables in your Supabase project.
Configure Table Display
Supamode auto-detects column types, but a few tweaks make the editing experience much better:
For the content_items table:
- Set Display Format to
{title}so records show their title instead of UUID in relationship dropdowns - Set the
contentcolumn's UI Data Type toMarkdownfor the rich Markdown editor - Set the
statuscolumn to show as a select dropdown - Set
published_atto show as a date picker - Hide
created_atandupdated_atfrom the create form (they auto-populate)
For the categories table:
- Set Display Format to
{name} - Set
colorto the Color data type
For the tags table:
- Set Display Format to
{name}
With these settings, your team gets a clean editing interface. The Markdown editor supports headings, code blocks, images, links, and tables. The status dropdown controls the publish state, and the published_at timestamp auto-fills from the trigger you created earlier.
Click to expandHide or disable any columns that you don't want to be editable by the users. The cleaner the interface, the better the experience for your team.
Click to expandWriting Content
Now that we have the tables set up, we can start writing content in the Supamode CMS.
Before adding a content item, you may want to create a category and a tag to assign to the content item (but feel free to skip this step if you don't want to use categories and tags).
- Navigate to the
content_itemstable - Click the
Newbutton - Fill in the required fields
- Click the
Savebutton
Click to expandAfter creating the content item, you should be able to see it in the table view:
Click to expandOnce the content item is created, you should be able to see it in your blog, docs or changelog pages:
Click to expandSet Up Editor Permissions
This is not required for your user as it has broad access to the Supabase project, but if you want to give team members editing access without full admin privileges, you can create a dedicated role in Supamode: Content Editor.
If you want to give team members editing access without full admin privileges, create a dedicated role in Supamode:
- Navigate to Settings > Permissions > Roles
- Create a role called "Content Editor"
This way your content team can publish blog posts and manage categories without accessing user data, billing records, or other sensitive tables.
Click to expandAssigning Data Permissions
- Navigate to Settings > Permissions > Permissions
- Create a permission called "Manage Content Items" or any other name that you prefer for the table you want to manage.
- Assign the "Manage Content Items" permission to the "Content Editor" role
Click to expandAssigning the Permission to the Role
- Navigate to Settings > Permissions > Roles
- Select the "Content Editor" role
- Assign the "Manage Content Items" permission to the "Content Editor" role
Click to expandYou need to do the same for every table that you want to be editable by the content team.
You can speed it up by placing these permissions in a permission group and assigning the group to the role. You can create a permission group called "Content Management" and assign the "Manage Content Items" permission to it. Then assign the "Content Management" group to the "Content Editor" role.
Nested Documentation with the Supabase CMS
The content_items table supports parent-child relationships through the parent_id and order columns. This maps to Makerkit's documentation tree structure where pages can be nested under parent pages.
To create hierarchical docs in Supamode:
- Create a parent item with
collection: 'documentation'and noparent_id - Create child items referencing the parent's ID
- Use the
ordercolumn to control sort order within a level
The SupabaseCmsClient returns parentId in the DTO, and Makerkit's docs layout uses it to build the sidebar navigation tree.
Supabase CMS vs Keystatic: When to Switch
If more than one person on your team publishes content, switch to a Supabase CMS with Supamode. You eliminate the "can you merge my blog post PR?" bottleneck, your content team gets a real editor, and everything stays in your Postgres database. Solo developers who prefer Git-based content and local file editing should stick with Keystatic.
Frequently Asked Questions
What is a Supabase CMS?
Does switching to a Supabase CMS require changing my blog pages?
Can I use Supabase as a headless CMS?
What happens to my existing Keystatic content when I switch?
Do I need Supamode to use Supabase as a CMS?
How does content rendering work with Markdown stored in Postgres?
Can I add custom fields to the Supabase CMS schema?
How does a Supabase CMS compare to Contentful or Sanity?
Next Steps
- Learn more about Supamode, the Supabase CMS and what it can do beyond content management
- Read the Supamode documentation for advanced features like custom dashboards and storage management
- Check out Building a Supabase Blog with Next.js for a standalone blog approach without the CMS abstraction