# How to Store Makerkit Content in Postgres with the CMS Plugin System

> Swap Makerkit's default Keystatic provider for Postgres. Build a content client against the CMS plugin registry, wire it into Next.js, and edit with Supamode — zero frontend changes.

*Published: 2026-02-06*
*Canonical: https://makerkit.dev/blog/tutorials/supabase-cms-integration-makerkit*

---

**Makerkit ships with Keystatic as the default content provider, and its CMS plugin system lets you swap that provider without touching a single component.** This guide walks through building a Postgres-backed provider: your content (blog posts, documentation, changelogs) lives in your Supabase database, and your Next.js app reads it at build or request time through the same `createCmsClient()` interface it already uses.

You'll need a working Makerkit Next.js Supabase Turbo project, Docker for local Supabase, and a copy of Supamode installed.

If you're looking for the editing interface rather than the plugin internals, that's [Supamode, the Supabase CMS](/supabase-cms) — it reads your Postgres schema and generates the admin panel described in the second half of this guide.

## Why Store Content in Postgres?

Most headless CMS platforms (Contentful, Sanity, Strapi) store your content on someone else's infrastructure. Keeping content in Postgres means it lives in the same 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](https://makerkit.dev/supabase-cms) 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:

```typescript
// 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 Postgres Content 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.

## The Content Database Schema

Create a new migration from the root of your Makerkit project:

```bash
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.

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

```bash
pnpm --filter web supabase migrations up
```

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

```bash
pnpm supabase:web:typegen
```

## Building the Content Client

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

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

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

```typescript {% title="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:

```typescript {% title="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:

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

```typescript {% 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>();

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:

```typescript {% title="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:

```bash
CMS_CLIENT=supabase
```

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

```bash
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](/supabase-cms) 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 Content Editor

[Supamode](/supabase-cms) is a self-hosted admin panel purpose-built as a [CMS for Supabase](/supabase-cms). 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](https://makerkit.dev/docs/supamode/installation/introduction).

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

```sql
select id from auth.users where email = 'your-email@example.com';
```

Generate the Supamode seed from the Supamode project root:

```bash
pnpm run generate-schema \
  --template saas \
  --root-account YOUR_AUTH_USER_ID
```

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

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

```bash
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):

```bash {% title="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)

```bash {% title="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:

```bash
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`.

```bash
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.

{% img src="/images/posts/supabase-cms-sync-tables.webp" width="1920" height="1080" alt="Supamode sync tables button" /%}

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.

{% img src="/images/posts/supabase-cms-table-settings.webp" width="1920" height="1080" alt="Supamode tables settings" /%}

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.

{% img src="/images/posts/supabase-cms-columns.webp" width="1920" height="1080" alt="Supamode content items table columns configuration" /%}

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

{% img src="/images/posts/supabase-cms-write-content-item.webp" width="1920" height="1080" alt="Supamode new content item" /%}

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

{% img src="/images/posts/supabase-cms-content-items-table.webp" width="1920" height="1080" alt="Supamode content items table" /%}

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

{% img src="/images/posts/supabase-cms-changelog-page.webp" width="1920" height="1080" alt="Supamode content changelog page" /%}

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

{% img src="/images/posts/supabase-cms-create-role.webp" width="1920" height="1080" alt="Supamode roles settings" /%}

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

{% img src="/images/posts/supabase-cms-create-permission.webp" width="1920" height="1080" alt="Supamode create content permission" /%}

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

{% img src="/images/posts/supabase-cms-assign-permission.webp" width="1920" height="1080" alt="Supamode assign permission to role" /%}

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

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.

## Postgres vs Keystatic: When to Switch

**If more than one person on your team publishes content**, switch to a Supabase CMS with [Supamode](/supabase-cms). 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.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "How does Makerkit's CMS plugin system work?", "answer": "The @kit/cms package exposes a createCmsClient() factory backed by a registry. Each provider implements the CmsClient abstract class with six methods, registers itself under a key, and is selected at runtime by the CMS_CLIENT environment variable. Your pages call the factory and work against the interface, never a concrete provider."},
     {"question": "Does switching providers require changing my blog pages?", "answer": "No. Makerkit's CMS abstraction means your blog, docs, and changelog pages work against an interface, not a specific provider. Switching from Keystatic to Postgres requires zero frontend changes."},
     {"question": "Can I use Supabase as a headless CMS?", "answer": "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."},
     {"question": "What happens to my existing Keystatic content when I switch?", "answer": "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."},
     {"question": "Do I need Supamode to use Supabase as a CMS?", "answer": "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."},
     {"question": "How does content rendering work with Markdown stored in Postgres?", "answer": "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."},
     {"question": "Can I add custom fields to the Supabase CMS schema?", "answer": "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."},
     {"question": "Can I run Keystatic and the Postgres provider side by side?", "answer": "The CMS_CLIENT environment variable selects one provider per deployment, so a single app instance uses one at a time. Because both implement the same CmsClient interface, you can register both and flip the variable per environment — for example Keystatic locally and Postgres in production — without touching page code."}
   ]
/%}

## Next Steps

- Learn more about [Supamode, the Supabase CMS](/supabase-cms) and what it can do beyond content management
- Read the [Supamode documentation](/docs/supamode) for advanced features like custom dashboards and storage management
- Check out [Building a Supabase Blog with Next.js](/blog/tutorials/create-blog-supabase) for a standalone blog approach without the CMS abstraction
