Supabase CMS: How to Manage Content in Postgres with Supamode and Makerkit

Build a Supabase CMS that stores blog posts, docs, and changelogs in Postgres. Use Supamode as the visual editor and Makerkit's CMS plugin system to serve content in Next.js.

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

Paste 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 enum
create type public.content_status as enum (
'draft', 'published', 'archived'
);
-- Categories table
create 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 table
create 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 edit
create 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 <-> categories
create 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 <-> tags
create 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 index
create index if not exists idx_content_items_collection_status_published
on public.content_items (collection, status, published_at desc);

Design notes:

  • Unified content_items table instead of separate posts, docs, changelog tables. The collection column distinguishes them. This maps directly to Makerkit's CMS abstraction where getContentItems({ collection: 'posts' }) filters by collection.
  • Compound unique constraint on (collection, slug) so the same slug can exist in different collections (e.g. /blog/getting-started and /docs/getting-started).
  • Check constraint on published_at forces published content to always have a timestamp. The trigger auto-fills it when an item transitions to published, 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 anon role, 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 up

Then regenerate the TypeScript types so the Supabase client knows about the new tables:

pnpm supabase:web:typegen

Building the Supabase CMS Client

Create a new CMS package for the Supabase provider. First, set up the package structure:

pnpm turbo gen package

In 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 the anon role, 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 the server-only import). The status filter 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: true is passed, the raw Markdown stored in Postgres gets parsed and rendered to React elements. When content: 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 provider
cmsRegistry.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=supabase

Your blog, docs, and changelog pages now pull content from Supabase instead of Keystatic files. Rebuild and verify:

pnpm dev

Visit /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_ID

Create a migration in your Makerkit project for the Supamode schema:

pnpm --filter web supabase migration new supamode-schema

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

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

In the API project, switch these variables to the actual Supabase instance (from the Supamode project)

apps/api/.env

SUPABASE_URL=http://localhost:54321
SUPABASE_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres

Run the Supamode Project

Run the following command to start the Supamode project from the Supamode project root:

pnpm dev

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

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

Supamode sync tables buttonClick to expand

After 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 content column's UI Data Type to Markdown for the rich Markdown editor
  • Set the status column to show as a select dropdown
  • Set published_at to show as a date picker
  • Hide created_at and updated_at from the create form (they auto-populate)

For the categories table:

  • Set Display Format to {name}
  • Set color to 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.

Supamode tables settingsClick to expand

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

Supamode content items table columns configurationClick to expand

Writing 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).

  1. Navigate to the content_items table
  2. Click the New button
  3. Fill in the required fields
  4. Click the Save button
Supamode new content itemClick to expand

After creating the content item, you should be able to see it in the table view:

Supamode content items tableClick to expand

Once the content item is created, you should be able to see it in your blog, docs or changelog pages:

Supamode content changelog pageClick to expand

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

  1. Navigate to Settings > Permissions > Roles
  2. 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.

Supamode roles settingsClick to expand

Assigning Data Permissions

  1. Navigate to Settings > Permissions > Permissions
  2. Create a permission called "Manage Content Items" or any other name that you prefer for the table you want to manage.
  3. Assign the "Manage Content Items" permission to the "Content Editor" role
Supamode create content permissionClick to expand

Assigning the Permission to the Role

  1. Navigate to Settings > Permissions > Roles
  2. Select the "Content Editor" role
  3. Assign the "Manage Content Items" permission to the "Content Editor" role
Supamode assign permission to roleClick to expand

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

  1. Create a parent item with collection: 'documentation' and no parent_id
  2. Create child items referencing the parent's ID
  3. Use the order column 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?
A Supabase CMS is a content management system that stores blog posts, documentation, and other content as rows in a Supabase Postgres database instead of in files or a third-party platform. You query content through Supabase's API and manage it with a visual editor like Supamode.
Does switching to a Supabase CMS require changing my blog pages?
No. Makerkit's CMS abstraction means your blog, docs, and changelog pages work against an interface, not a specific provider. Switching from Keystatic to Supabase requires zero frontend changes.
Can I use Supabase as a headless CMS?
Yes. Supabase works as a headless CMS when you store structured content in Postgres tables and query it through the Supabase client. This guide builds exactly that pattern: a content schema in Postgres, a TypeScript client that reads it, and Supamode as the editing interface.
What happens to my existing Keystatic content when I switch?
It stays in your Git repo. You'll need to migrate it to Supabase, either manually or with a script that reads Markdoc files and inserts them as content_items rows. The content format (Markdown) is the same in both systems.
Do I need Supamode to use Supabase as a CMS?
No. You could manage content by inserting rows directly in Supabase Studio or through custom admin pages. Supamode adds the rich Markdown editing, RBAC, and audit logging that make a Supabase CMS practical for teams. See makerkit.dev/supabase-cms for details.
How does content rendering work with Markdown stored in Postgres?
The SupabaseCmsClient uses @markdoc/markdoc to parse and render Markdown content to React elements server-side. This happens at query time when content: true is passed, so list views skip rendering for performance.
Can I add custom fields to the Supabase CMS schema?
Yes. Add columns to the content_items table via a Supabase migration. Supamode will auto-detect them after running sync_managed_tables. Update the mapContentItemToDto function to include any new fields in the CMS interface.
How does a Supabase CMS compare to Contentful or Sanity?
Contentful and Sanity are hosted platforms with their own APIs and pricing. A Supabase CMS keeps content in your own Postgres database with no vendor lock-in, no per-seat pricing, and full SQL access. The tradeoff is you manage the editing experience yourself, which is where Supamode fills the gap.

Next Steps