# Build a Production Supabase Blog in Next.js with Supamode CMS

> Learn how to design a Supabase blog schema, build a Next.js UI, and wire up Supamode CMS so your team can publish fast without touching SQL

*Published: 2025-10-13*
*Canonical: https://makerkit.dev/blog/tutorials/create-blog-supabase*

---

If you’ve ever wanted to build a simple yet scalable blog system with Supabase, this guide is for you. 

In this tutorial, we’ll design a relational schema for posts, categories, comments, and authors — complete with Row Level Security (RLS) and a UI for displaying the blog posts.

To save time, we’ll use the **[MakerKit open-source starter kit](https://github.com/makerkit/nextjs-saas-starter-kit-lite)**, which gives us a ready-to-use Next.js app with authentication, layout, and UI components out of the box. That means we can focus purely on the Supabase blog logic — not boilerplate.

By the end, you’ll have a clean, production-ready Supabase setup with a functional blog backend and frontend. And best of all, we’ll show how **[Supamode](https://makerkit.dev/supabase-cms)** can instantly turn this schema into a full-featured CMS on top of Supabase — **no code required**.

## **1. Introduction**

Supabase makes it incredibly easy to build modern apps that rely on a powerful PostgreSQL database — but turning that data into a usable content management system (CMS) often requires custom dashboards, APIs, and UI work.

In this post, we’ll show you exactly how to build a **blog system with Supabase**, starting from the database schema all the way to a CMS-ready setup. 

To speed things up, we’ll build on top of the **MakerKit Lite Starter Kit**, an open-source Next.js + Supabase boilerplate available to everyone. MakerKit provides ready-made authentication, layouts, and design components — allowing us to focus on the **posts**, **categories**, **comments**, and **authors** that make up our blog system.

You’ll learn how to:

* **Tables**: Create the essential **tables** for posts, categories, authors, and comments
* **Relationships**: Define **relationships** that make querying your data simple
* **Row Level Security (RLS)**: Add **Row Level Security (RLS)** to protect and manage author and editor access
* **UI**: Building the UI for the blog system
* **CMS**: Use **Supamode** to instantly transform your Supabase project into a production-ready CMS

Whether you’re building a personal blog, a content platform, or a team publishing tool, this **Supabase blog tutorial** will walk you through every step — from schema design to a live CMS built on top of Supabase and Supamode.

---

# **2. Setting Up Your Supabase Project**

Before we create the blog schema, let’s start by setting up Supabase and connecting it to the MakerKit Lite starter kit.

### Install Pnpm

If you don't have pnpm installed, you can install it using the following command:

```bash
npm i -g pnpm
```

### Clone the MakerKit Starter

We’ll use the open-source **MakerKit Lite Starter** to skip boilerplate and get authentication, layout, and UI components ready from the start.

```bash
git clone https://github.com/makerkit/nextjs-saas-starter-kit-lite.git supabase-blog-tutorial
cd supabase-blog-tutorial
```

Now, let's install the dependencies:

```bash
pnpm i
```

### Run Supabase locally

Before running the project, we need to run Supabase locally. Supabase requires Docker to be running, so make sure to install it first. You can use Docker Desktop, Colima, or OrbStack - just make sure you have it running.

From the root of the project, run the following command:

```bash
pnpm run supabase:web:start
```

This command will start the Supabase local environment.

### Connect Your App

Run the project locally to verify everything is working:

```bash
pnpm dev
```

Then visit [http://localhost:3000](http://localhost:3000) — you should see MakerKit’s default UI, complete with Supabase Auth already wired up.

## Defining the Core Tables

We’ll create four tables: `authors`, `categories`, `posts`, and `comments`.
Principles we’ll follow:

* **RLS on every table** (explicitly enabled)
* **Public reads only**
* **No complex author/editor RLS** in SQL — we will rely on **Supamode** (the Supabase CMS) to allow editing

---

# **3. Defining the Database Schema**

Let's define the database schema for the blog. We'll create the following tables:

* `authors`
* `categories`
* `posts`

Furthermore, we will define the RLS (Row Level Security) policies for the tables. These ensure that only the authorized users can access the tables and perform actions on the data.

## Creating the migration

First, we need to create a migration file. We can do this by running the following command:

If you prefer generating migrations from schema changes, follow the [Supabase migrations](/docs/next-supabase-turbo/development/migrations#creating-a-migration-file-from-a-schema-change) workflow instead.

```bash
pnpm --filter web supabase migration new blog-schema
```

This command will generate a migration file in the `apps/web/supabase/migrations` directory.

You can copy the content of the schema file to the migration file as we walk through the tables in the following sections.

## Post Publication Status

We'll create an enum type for the post publication status. This will be used to store the status of the post.

```sql
-- Post publication status
create type post_status as enum ('draft', 'published', 'archived');
```

## Authors Table

Maps app users to public author profiles. You can add any additional fields to the table if you need to.

```sql
create table if not exists public.authors (
  id           uuid primary key default gen_random_uuid(),
  -- Link to Supabase Auth user (required for authenticated actions)
  user_id      uuid not null unique references auth.users(id) on delete cascade,
  public_data  jsonb default '{
  "display_name": "",
  "avatar_url": "",
  "bio": ""
  }'::jsonb not null,
  created_at   timestamptz not null default now(),
  updated_at   timestamptz not null default now()
);

alter table public.authors enable row level security;
```

### RLS Policies for Authors Table

* Public can **read** author public profile fields.
* Only the logged-in owner can **update** their row.

```sql
-- SELECT: anyone can read author public info
create policy authors_public_read
  on public.authors
  for select
  to anon, authenticated
  using (true);

-- No insert/delete from public; staff do it via Supamode
```

## Categories Table

Now, we will create the `categories` table. This table will store the categories of the posts.

```sql
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,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now(),
  color       text default '#000000',
  check (length(trim(slug)) > 0),
  check (length(trim(name)) > 0)
);

alter table public.categories enable row level security;

-- Public read
create policy categories_public_read
  on public.categories
  for select
  to anon, authenticated
  using (true);

-- No public writes; staff writes happen via CMS
```

## Posts Table

Now, we will create the `posts` table. This table will store the posts of the blog.

This table will connect to the `authors` and `categories` tables, since it's the author that created the post and the category that the post belongs to.

```sql
create table if not exists public.posts (
  id            uuid primary key default gen_random_uuid(),
  author_id     uuid not null references public.authors(id) on delete restrict,
  category_id   uuid references public.categories(id) on delete set null,
  slug          text not null unique,
  title         text not null,
  description   text,
  content       text not null,
  status        post_status not null default 'draft',
  published_at  timestamptz,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now(),

  -- Integrity & correctness
  check (length(trim(slug)) > 0),
  check (length(trim(title)) > 0),
  check (
    (status = 'published' and published_at is not null)
    or (status in ('draft','archived') and published_at is null)
  )
);

alter table public.posts enable row level security;
```

### RLS Policies for Posts Table

Now, we will create the RLS policies for the `posts` table. The RLS will filter out the posts that are not published.

```sql
-- Public read of published posts only
create policy posts_public_read_published
  on public.posts
  for select
  to anon, authenticated
  using (status = 'published');
```

## Reviewing the Schema

Let's review the schema. We've created the following tables:

* `authors`
* `categories`
* `posts`

Our RLS policies allow all users to read the the tables, which are supposed to be read-only and only contain publicly accessible data. Anything that should be protected from public access should be added to separate tables and protected by RLS policies.

## Applying the migration

Now, we can apply the migration to the database, which will create the tables and the RLS policies. Let's run the following command from the root of the project:

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

Learn more about [running Supabase migrations](/docs/next-supabase-turbo/development/migrations) and how to [push them to a remote Supabase instance](/docs/next-supabase-turbo/development/migrations#pushing-the-migration-to-the-remote-supabase-instance).

Let's also generate the TypeScript types for the database:

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

This will generate the TypeScript types in the `apps/web/supabase/types` directory and will allow the Supabase client to infer the types of the database tables and columns.

## Seeding the Database

So that we can test the blog, we need to seed the database with some data.

Here's a snippet you can execute in the SQL Editor to seed the database with some data:

```sql
-- Sample data for the blog experience ---------------------------------------

-- Upsert demo auth users that back the blog authors
insert into auth.users (
  id,
  instance_id,
  aud,
  role,
  email,
  encrypted_password,
  email_confirmed_at,
  confirmation_sent_at,
  last_sign_in_at,
  raw_user_meta_data,
  raw_app_meta_data,
  is_super_admin,
  created_at,
  updated_at,
  is_anonymous
)
values
  (
    '6f3b0c10-2c41-4c4e-a52f-6a8f1c876501'::uuid,
    '00000000-0000-0000-0000-000000000000'::uuid,
    'authenticated',
    'authenticated',
    'casey.winters@example.com',
    '$2a$12$wFiWJTx5bqlx3z4YB9H9FO3FoZT3zh/OElBTtPlqvGjufH6G6D/ad',
    now(),
    now(),
    now(),
    jsonb_build_object(
      'name',
      'Casey Winters',
      'avatar_url',
      'https://images.unsplash.com/photo-1533038590840-1cde6e668a91?auto=format&fit=facearea&w=256&h=256&q=80',
      'bio',
      'Product storyteller keeping you in the loop on everything shipping.'
    ),
    jsonb_build_object('provider', 'email', 'providers', array ['email']),
    false,
    now(),
    now(),
    false
  ),
  (
    '4d8b76f5-9398-4a78-8a33-78c6a3f894c4'::uuid,
    '00000000-0000-0000-0000-000000000000'::uuid,
    'authenticated',
    'authenticated',
    'nate.liang@example.com',
    '$2a$12$wFiWJTx5bqlx3z4YB9H9FO3FoZT3zh/OElBTtPlqvGjufH6G6D/ad',
    now(),
    now(),
    now(),
    jsonb_build_object(
      'name',
      'Nate Liang',
      'avatar_url',
      'https://images.unsplash.com/photo-1485137248855-075c8dfdedd6?auto=format&fit=facearea&w=256&h=256&q=80',
      'bio',
      'Engineering lead sharing deep dives from the trenches.'
    ),
    jsonb_build_object('provider', 'email', 'providers', array ['email']),
    false,
    now(),
    now(),
    false
  )
on conflict (id) do update
  set email = excluded.email,
      raw_user_meta_data = excluded.raw_user_meta_data,
      raw_app_meta_data = excluded.raw_app_meta_data,
      updated_at = now();

-- Upsert authors using the user records above
insert into public.authors (id, user_id, public_data, created_at, updated_at)
values
  (
    '5241b9a0-6057-4c4d-a713-3d962b9d4a35'::uuid,
    '6f3b0c10-2c41-4c4e-a52f-6a8f1c876501'::uuid,
    jsonb_build_object(
      'display_name',
      'Casey Winters',
      'avatar_url',
      'https://images.unsplash.com/photo-1533038590840-1cde6e668a91?auto=format&fit=facearea&w=256&h=256&q=80',
      'bio',
      'Product storyteller keeping you in the loop on everything shipping.'
    ),
    now(),
    now()
  ),
  (
    'e03e9f97-2cf7-4790-a1ee-1f1d3ef0f0fb'::uuid,
    '4d8b76f5-9398-4a78-8a33-78c6a3f894c4'::uuid,
    jsonb_build_object(
      'display_name',
      'Nate Liang',
      'avatar_url',
      'https://images.unsplash.com/photo-1485137248855-075c8dfdedd6?auto=format&fit=facearea&w=256&h=256&q=80',
      'bio',
      'Engineering lead sharing deep dives from the trenches.'
    ),
    now(),
    now()
  )
on conflict (user_id) do update
  set public_data = excluded.public_data,
      updated_at = now();

-- Upsert blog categories
insert into public.categories (id, slug, name, description, color, created_at, updated_at)
values
  (
    'd6bfa5ab-1d65-4de3-8f12-4dafc2f7dbe1'::uuid,
    'product-updates',
    'Product updates',
    'Learn about the features and improvements landing in the product.',
    '#6366F1',
    now(),
    now()
  ),
  (
    '0b540318-8e95-4b81-a41c-247e0c76d785'::uuid,
    'engineering',
    'Engineering',
    'In-depth looks at how we build, scale, and operate the platform.',
    '#14B8A6',
    now(),
    now()
  ),
  (
    'c2fe7f6d-2559-46d4-8c17-93915c93dffa'::uuid,
    'guides',
    'Guides',
    'Hands-on walkthroughs to help you get the most out of the product.',
    '#F59E0B',
    now(),
    now()
  )
on conflict (slug) do update
  set name = excluded.name,
      description = excluded.description,
      color = excluded.color,
      updated_at = now();

-- Upsert blog posts
insert into public.posts (
  id,
  author_id,
  category_id,
  slug,
  title,
  description,
  content,
  status,
  published_at,
  created_at,
  updated_at
)
values
  (
    '6a92f829-4b0a-46d9-8d2d-11db7e1c155a'::uuid,
    '5241b9a0-6057-4c4d-a713-3d962b9d4a35'::uuid,
    'd6bfa5ab-1d65-4de3-8f12-4dafc2f7dbe1'::uuid,
    'introducing-the-fastest-way-to-launch',
    'Introducing the fastest way to launch',
    'A look at the small, meaningful improvements we shipped this month to smooth out the path from idea to production.',
    $$
    ## Ship faster with thoughtful polish

    Over the past sprint we tackled the tiny paper cuts that slow growing teams down.

    - Expanded quickstart templates with opinionated defaults
    - Added deployment preview insights right inside the dashboard
    - Simplified audit trails so reviewers can approve in seconds

    ### What’s next

    We are doubling down on release confidence with smarter alerts and change logs. Stay tuned — the next update lands soon.
    $$,
    'published',
    '2025-01-08T10:00:00.000Z',
    now(),
    now()
  ),
  (
    '8ffb0421-f2fb-4f7d-8a2a-8c4e61b98d02'::uuid,
    'e03e9f97-2cf7-4790-a1ee-1f1d3ef0f0fb'::uuid,
    '0b540318-8e95-4b81-a41c-247e0c76d785'::uuid,
    'designing-a-secure-postgres-multitenant-setup',
    'Designing a secure Postgres multi-tenant setup',
    'How we designed the new role-based access layer that keeps tenant data isolated while preserving developer ergonomics.',
    $$
    ## The challenge

    Multi-tenant architectures require strict boundaries without sacrificing developer velocity. With Row Level Security we can codify the rules once and reuse them everywhere.

    ```sql
    create policy "Users can select their own organization data"
      on public.projects
      for select
      using (auth.uid() = owner_id);
    ```

    ### Lessons learned

    1. Model simple first — complexity follows usage patterns.
    2. Dogfood the policies in staging to catch surprising edge cases early.
    $$,
    'published',
    '2025-01-15T14:30:00.000Z',
    now(),
    now()
  ),
  (
    '35184d49-44ef-4b3a-8c74-c99b26b8b2f6'::uuid,
    '5241b9a0-6057-4c4d-a713-3d962b9d4a35'::uuid,
    'c2fe7f6d-2559-46d4-8c17-93915c93dffa'::uuid,
    'crafting-a-minimal-marketing-site-in-an-afternoon',
    'Crafting a minimal marketing site in an afternoon',
    'A hands-on guide that shows how to take the starter kit from zero to a polished marketing page with Supabase and Next.js.',
    $$
    ## Start from the basics

    Focus on the key story: who you serve, the problem you solve, and the proof you have traction.

    > The best marketing sites remove decisions. Each component nudges visitors to the next action.

    ### Checklist we follow

    - Establish a clear, scannable hero section.
    - Highlight the core product story with supporting visuals.
    - Close with social proof and decisive calls to action.
    $$,
    'published',
    '2025-01-22T09:15:00.000Z',
    now(),
    now()
  )
on conflict (slug) do update
  set title = excluded.title,
      description = excluded.description,
      content = excluded.content,
      status = excluded.status,
      category_id = excluded.category_id,
      author_id = excluded.author_id,
      published_at = excluded.published_at,
      updated_at = now();
```

# 4. Building a UI for the blog

With the schema in place, we can now build a UI for the blog. I'll try to keep the UI simple but functional.

To keep this Supabase blog UI fast and search-friendly, we lean on **React Server Components** rendered through the **Next.js App Router**. Every Supabase call happens server-side, which means the HTML that reaches the browser is already populated with content — a big win for Core Web Vitals and SEO.

We organize the marketing-facing UI under `apps/web/app/(marketing)/blog` so it reuses MakerKit’s typography, spacing, and UI primitives while staying separate from the authenticated app shell. The UI layer follows three principles:

- Fetch just the data we need using cached Supabase queries.
- Feed that data into composable components that match the design system.
- Render polished blog listing and detail views with great defaults (filters, related posts, readable dates).

## Data Fetching

The first step is to create the queries to fetch the data from the database. We'll add them to the `apps/web/lib/blog/queries.ts` file.

Centralizing the database access in a single module keeps React components thin and makes caching straightforward. Each helper is wrapped with `cache` so Next.js memoizes the result for the duration of the request, eliminating duplicate round-trips if multiple components need the same data.

We use the `getSupabaseServerClient` function to get the Supabase client and fetch the data from the database. This function is predefined in the Makerkit Lite starter kit and allows to create a Supabase client that works server-side. We fetch the data server side for SEO reasons.

```tsx {% title="apps/web/lib/blog/queries.ts" %}
import 'server-only';

import { cache } from 'react';

import { getSupabaseServerClient } from '@kit/supabase/server-client';

import type { Database } from '~/lib/database.types';

type Tables = Database['public']['Tables'];

type CategoryRow = Tables['categories']['Row'];
type PostRow = Tables['posts']['Row'];
export type AuthorRow = Tables['authors']['Row'];

export type AuthorPublicData = {
  display_name?: string;
  avatar_url?: string;
  bio?: string;
};

export type BlogCategory = Pick<
  CategoryRow,
  'id' | 'name' | 'slug' | 'description' | 'color'
>;

export type BlogPostListItem = PostRow & {
  content: PostRow['content'];
  author: AuthorRow;
  category: CategoryRow;
};

export const getBlogCategories = cache(async () => {
  const supabase = getSupabaseServerClient();

  const { data, error } = await supabase
    .from('categories')
    .select('id, slug, name, description, color')
    .order('name', { ascending: true });

  if (error) {
    throw error;
  }

  return data;
});

export const getPublishedPosts = cache(async (categoryId?: string) => {
  const supabase = getSupabaseServerClient();

  let query = supabase
    .from('posts')
    .select<string, BlogPostListItem>(
      `
        *,
        author:authors!posts_author_id_fkey(
          id,
          public_data,
          updated_at,
          created_at,
          user_id
        ),
        category:categories!posts_category_id_fkey(
          id,
          slug,
          name,
          description,
          color,
          created_at,
          updated_at
        )
      `,
    )
    .eq('status', 'published')
    .not('published_at', 'is', null)
    .order('published_at', { ascending: false })
    .order('created_at', { ascending: false });

  if (categoryId) {
    query = query.eq('category_id', categoryId);
  }

  const { data, error } = await query;

  if (error) {
    throw error;
  }

  return data;
});

export const getPublishedPostBySlug = cache(async (slug: string) => {
  const supabase = getSupabaseServerClient();

  const { data, error } = await supabase
    .from('posts')
    .select<string, BlogPostListItem>(
      `
        *,
        author:authors!posts_author_id_fkey(
          id,
          public_data,
          updated_at,
          created_at,
          user_id
        ),
        category:categories!posts_category_id_fkey(
          id,
          slug,
          name,
          description,
          color,
          created_at,
          updated_at
        )
      `,
    )
    .eq('slug', slug)
    .eq('status', 'published')
    .not('published_at', 'is', null)
    .limit(1)
    .maybeSingle();

  if (error) {
    throw error;
  }

  if (!data) {
    return null;
  }

  return {
    ...data,
    content: data.content,
  };
});
```

Each query returns fully hydrated objects so the React tree doesn’t perform extra lookups:

- `getBlogCategories` retrieves category IDs, names, and colors to power the filter UI.
- `getPublishedPosts` assembles a post list with the author and category preloaded, optionally filtered by category ID.
- `getPublishedPostBySlug` fetches a single post for the detail page and gracefully returns `null` when a slug is missing.

Because the Supabase policies already restrict access to published content, the UI automatically respects publication status without additional checks.

## Components

Now, we can create the components to display the blog posts. We'll add them to the `apps/web/app/(marketing)/blog/_components` directory.

The goal is to keep every component focused on presentation. Data flows in through props, and styling is handled with the existing MakerKit utility classes. This makes it easy to customize individual elements (for example, swapping in a different avatar component) without rewriting the broader blog experience.

```tsx {% title="apps/web/app/(marketing)/blog/_components/avatar-meta.tsx" %}
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';

import { AuthorPublicData, AuthorRow } from '~/lib/blog/queries';

const avatarSizeMap = {
  sm: 'h-10 w-10',
  md: 'h-12 w-12',
} as const;

export function AuthorMeta(props: {
  author: AuthorRow;
  showBio?: boolean;
  avatarSize?: keyof typeof avatarSizeMap;
  className?: string;
}) {
  const publicData = props.author.public_data as AuthorPublicData;
  const initials = getAuthorInitials(publicData.display_name || '');
  const avatarSize = avatarSizeMap[props.avatarSize ?? 'sm'];

  return (
    <div
      className={['flex items-center gap-3', props.className]
        .filter(Boolean)
        .join(' ')}
    >
      <Avatar className={avatarSize}>
        <AvatarImage src={publicData.avatar_url ?? undefined} alt="" />

        <AvatarFallback className="text-sm font-semibold uppercase">
          <span suppressHydrationWarning>{initials}</span>
        </AvatarFallback>
      </Avatar>

      <div className="flex flex-col leading-tight">
        <span className="font-medium">{publicData.display_name}</span>

        {props.showBio && publicData.bio ? (
          <span className="text-muted-foreground text-sm">
            {publicData.bio}
          </span>
        ) : null}
      </div>
    </div>
  );
}

export const getAuthorInitials = (displayName: string) => {
  if (!displayName || displayName.length === 0) {
    return '?';
  }

  const parts = displayName.trim().split(/\s+/);

  if (parts.length === 1) {
    return parts[0]!.slice(0, 2).toUpperCase();
  }

  return parts
    .slice(0, 2)
    .map((part) => part[0])
    .join('')
    .toUpperCase();
};
```

`AuthorMeta` wraps author details into a reusable badge. We parse the JSON `public_data` once, compute safe initials for fallback avatars, and expose configurable props so the same component works on the blog grid, detail page, and anywhere else you highlight authors.

### Post Card

```tsx {% title="apps/web/app/(marketing)/blog/_components/post-card.tsx" %}
import Link from 'next/link';

import type { BlogPostListItem } from '~/lib/blog/queries';

import { AuthorMeta } from './author-meta';
import { formatPublishedDate } from './utils';

export function PostCard(props: {
  post: BlogPostListItem;
  className?: string;
  showDescription?: boolean;
  showAuthorBio?: boolean;
}) {
  const formattedDate = formatPublishedDate(props.post.published_at);

  return (
    <article
      className={[
        'group bg-background rounded-2xl border p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-md',
        props.className,
      ]
        .filter(Boolean)
        .join(' ')}
    >
      <div className="flex flex-col gap-4">
        {props.post.category_id ? (
          <span
            className="text-xs font-semibold tracking-wide uppercase"
            style={
              props.post.category.color
                ? {
                    color: props.post.category.color,
                  }
                : undefined
            }
          >
            {props.post.category.name}
          </span>
        ) : null}

        <div className="flex flex-col gap-3">
          <Link
            href={`/blog/${props.post.slug}`}
            className="group-hover:text-primary text-2xl font-semibold tracking-tight text-balance transition"
          >
            {props.post.title}
          </Link>

          {props.showDescription !== false && props.post.description ? (
            <p className="text-muted-foreground text-sm leading-relaxed md:text-base">
              {props.post.description}
            </p>
          ) : null}
        </div>
      </div>

      <footer className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
        <AuthorMeta
          author={props.post.author}
          showBio={props.showAuthorBio !== false}
        />

        {formattedDate ? (
          <time
            dateTime={props.post.published_at ?? undefined}
            className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
          >
            {formattedDate}
          </time>
        ) : null}
      </footer>
    </article>
  );
}
```

The card stitches together the post title, description, category accent, author meta, and publish date into an accessible layout. Because it relies entirely on props, you can drop it into other routes (think “related posts” in product pages) without pulling in extra logic.

### Post Content

First, install the `react-markdown` package:

```bash
pnpm --filter web add react-markdown
```

```tsx {% title="apps/web/app/(marketing)/blog/_components/post-content.tsx" %}
import { MarkdownAsync } from 'react-markdown';

export function PostContent(props: { content: string }) {
  if (!props.content.trim()) {
    return null;
  }

  return (
    <div className={'markdoc'}>
      <MarkdownAsync>{props.content}</MarkdownAsync>
    </div>
  );
}
```

`PostContent` uses the async variant of `react-markdown` so server components can stream Markdown as HTML without client-side hydration. The guard clause keeps us from rendering empty wrappers when a draft post hasn’t been written yet.

### Utilities file

```tsx {% title="apps/web/app/(marketing)/blog/_components/utils.tsx" %}
import { format } from 'date-fns';

export const formatPublishedDate = (value: string | null) => {
  if (!value) {
    return null;
  }

  const date = new Date(value);

  if (Number.isNaN(date.getTime())) {
    return null;
  }

  try {
    return format(date, 'LLL d, yyyy');
  } catch {
    return null;
  }
};
```

Handling date formatting in a tiny utility keeps JSX tidy and ensures both the list and detail pages present consistent, localized dates. If you ever swap to a different format or locale, you only update this helper.

## Pages

### Post List

The marketing-facing blog index is a server component that resolves search parameters, fetches posts, and renders HTML in one pass. That means visitors (and crawlers) receive the final markup instantly, while still benefiting from the shared components we built above.

```tsx {% title="apps/web/app/(marketing)/blog/page.tsx" %}
import Link from 'next/link';
import { notFound } from 'next/navigation';

import { ArrowRightIcon } from 'lucide-react';

import {
  type BlogCategory,
  getBlogCategories,
  getPublishedPosts,
} from '~/lib/blog/queries';

import { PostCard as BlogPostCard } from './_components/post-card';

type BlogPageProps = {
  searchParams?: Promise<{
    category?: string;
  }>;
};

export const metadata = {
  title: 'Blog',
  description:
    'Insights, guides, and announcements to help you get the most out of the product.',
};

export default async function BlogPage(props: BlogPageProps) {
  const searchParams = await props.searchParams;

  const categorySlug =
    typeof searchParams?.category === 'string'
      ? searchParams.category.trim()
      : undefined;

  const categories = await getBlogCategories();

  const activeCategory =
    categorySlug && categorySlug.length > 0
      ? categories.find((category) => category.slug === categorySlug)
      : undefined;

  if (categorySlug && !activeCategory) {
    notFound();
  }

  const posts = await getPublishedPosts(activeCategory?.id);

  return (
    <div className={'flex flex-col space-y-12 pt-16 pb-24'}>
      <header className={'container mx-auto flex flex-col gap-6 px-6'}>
        <div className="flex max-w-2xl flex-col gap-4">
          <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
            Blog
          </span>
          <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
            Stories and updates from the team
          </h1>
          <p className="text-muted-foreground text-base md:text-lg">
            Learn what we are working on, discover product tips, and deep dive
            into the ideas shaping our roadmap.
          </p>
        </div>

        {categories.length > 0 ? (
          <CategoryFilter
            categories={categories}
            activeCategory={activeCategory}
          />
        ) : null}
      </header>

      <main className="container mx-auto px-6">
        {posts.length === 0 ? (
          <div className="border-muted-foreground/40 bg-muted/40 rounded-xl border border-dashed p-10 text-center">
            <div className="text-lg font-medium">No posts yet</div>
            <p className="text-muted-foreground mt-2 text-sm">
              Check back soon — we are busy writing something worth your time.
            </p>
          </div>
        ) : (
          <section className="grid grid-cols-1 gap-8 lg:grid-cols-2">
            {posts.map((post) => {
              return <BlogPostCard key={post.id} post={post} />;
            })}
          </section>
        )}
      </main>
    </div>
  );
}

function CategoryFilter(props: {
  categories: BlogCategory[];
  activeCategory?: BlogCategory;
}) {
  return (
    <nav className="flex flex-wrap items-center gap-2">
      <CategoryPill label={'All posts'} active={!props.activeCategory} />
      {props.categories.map((category) => {
        const isActive = props.activeCategory?.id === category.id;

        return (
          <CategoryPill
            key={category.id}
            label={category.name}
            slug={category.slug}
            active={isActive}
          />
        );
      })}
    </nav>
  );
}

function CategoryPill(props: {
  label: string;
  slug?: string;
  active?: boolean;
}) {
  const href = props.slug ? `/blog?category=${props.slug}` : '/blog';

  return (
    <Link
      href={href}
      className={[
        'inline-flex items-center gap-1 rounded-full border px-4 py-2 text-sm font-medium transition',
        props.active
          ? 'border-foreground bg-foreground text-background hover:bg-foreground'
          : 'border-border bg-background text-foreground hover:bg-muted',
      ].join(' ')}
      aria-current={props.active ? 'page' : undefined}
    >
      <span>{props.label}</span>
      {props.active ? <ArrowRightIcon className="h-3 w-3" aria-hidden /> : null}
    </Link>
  );
}
```

This page component resolves the category filter, renders a marketing-friendly hero section, and falls back to a warm empty state when no posts are published yet. Because the category pills link to actual URLs, visitors can bookmark filtered views and search engines can crawl them.

{% img src="/images/posts/blog-demo-list-page.webp" width="2970" height="2028" alt="Blog Demo List Page" /%}

### Post Detail

For the article page we reuse the same cached queries, generate metadata on the fly, and surface related reading pulled from the same category — all without introducing client-side fetching or complex state machines.

```tsx {% title="apps/web/app/(marketing)/blog/[slug]/page.tsx" %}
import type { Metadata } from 'next';

import Link from 'next/link';
import { notFound } from 'next/navigation';

import { ArrowLeftIcon } from 'lucide-react';

import { getPublishedPostBySlug, getPublishedPosts } from '~/lib/blog/queries';

import { AuthorMeta } from '../_components/author-meta';
import { PostCard as BlogPostCard } from '../_components/post-card';
import { PostContent } from '../_components/post-content';
import { formatPublishedDate } from '../_components/utils';

type BlogPostPageProps = {
  params: Promise<{
    slug: string;
  }>;
};

export async function generateMetadata(
  props: BlogPostPageProps,
): Promise<Metadata> {
  const { slug } = await props.params;
  const post = await getPublishedPostBySlug(slug);

  if (!post) {
    return {
      title: 'Post not found',
    };
  }

  return {
    title: `${post.title} • Blog`,
    description: post.description ?? undefined,
  };
}

export default async function BlogPostPage(props: BlogPostPageProps) {
  const { slug } = await props.params;
  const post = await getPublishedPostBySlug(slug);

  if (!post) {
    notFound();
  }

  const formattedDate = formatPublishedDate(post.published_at);

  const relatedCandidates = await getPublishedPosts(post.category?.id);

  const relatedPosts = relatedCandidates
    .filter((candidate) => candidate.id !== post.id)
    .slice(0, 3);

  return (
    <div className="flex flex-col space-y-16 pt-16 pb-24">
      <div className="container mx-auto flex flex-col gap-6 px-6">
        <Link
          href={'/blog'}
          className="text-muted-foreground hover:text-foreground inline-flex items-center gap-2 text-sm font-medium transition"
        >
          <ArrowLeftIcon className="h-4 w-4" />
          <span>Back to blog</span>
        </Link>

        <div className="flex flex-col gap-4">
          {post.category ? (
            <span
              className="text-xs font-semibold tracking-wide uppercase"
              style={
                post.category.color
                  ? {
                      color: post.category.color,
                    }
                  : undefined
              }
            >
              {post.category.name}
            </span>
          ) : null}

          <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
            {post.title}
          </h1>

          {post.description ? (
            <p className="text-muted-foreground text-lg md:text-xl">
              {post.description}
            </p>
          ) : null}
        </div>

        <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
          <AuthorMeta author={post.author} showBio avatarSize="md" />

          {formattedDate ? (
            <time
              dateTime={post.published_at ?? undefined}
              className="text-muted-foreground text-sm tracking-wide uppercase"
            >
              {formattedDate}
            </time>
          ) : null}
        </div>
      </div>

      <article className="container mx-auto flex flex-col gap-16 px-6">
        <PostContent content={post.content} />

        {relatedPosts.length > 0 ? (
          <section className="flex flex-col gap-6">
            <div>
              <h2 className="text-2xl font-semibold">
                {post.category
                  ? `More from ${post.category.name}`
                  : 'More from the blog'}
              </h2>
              <p className="text-muted-foreground text-sm">
                Continue exploring ideas that complement this article.
              </p>
            </div>

            <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
              {relatedPosts.map((relatedPost) => {
                return (
                  <BlogPostCard
                    key={relatedPost.id}
                    post={relatedPost}
                    showDescription={false}
                    showAuthorBio={false}
                  />
                );
              })}
            </div>
          </section>
        ) : null}
      </article>
    </div>
  );
}
```

The detail route takes care of everything an editor expects from a professional blog: dynamic `<title>` and `<meta>` tags for SEO, a hero section with author info, Markdown body rendering, and a related-posts grid to keep readers engaged. All of it runs as a server component, so Lighthouse scores stay high and Supabase remains the single source of truth.

With the marketing pages wired up, we now have a complete Supabase blog experience — schema, queries, and a polished Next.js blog layout. The final step is empowering non-technical teammates with a CMS.

{% img src="/images/posts/blog-demo-detail-page.webp" width="2970" height="2028" alt="Blog Demo Detail Page" /%}

# 5. Adding a Supabase CMS to the blog

Our application is ready with both the DB schema and the UI in place to display the blog in our Next.js application. Great work!

Now, we need to provide a way to the non-technical team members to manage the blog content. Surely, you don't want to write any SQL to do this! 

Instead, we can do this by adding a Supabase CMS to the blog. We will use [**Supamode**](https://makerkit.dev/supabase-cms) - **the best CMS for Supabase**.

**The following guide assumes you have a copy of Supamode installed and running**. If you don't, you can install it by following the [installation guide](https://makerkit.dev/docs/supamode/installation/introduction).

## Installing Supamode into your Supabase project

Supamode can be installed into your Supabase project by adding the Supamode schema to your Supabase database. Supamode will create a private schema named `supamode` and will install the tables and functions needed to run Supamode. Once synced the tables into the Supamode tables, you can start using the Supamode CMS to manage the blog content.

### Generating the Supamode schema

The first step into installing Supamode into your Supabase project is to generate the schema. To do so, we need the Auth ID of the user that will be the root account for the Supamode project.

You can find the Auth ID of the user by looking at the `auth.users` table in your Supabase project or simply usng Supabase Studio. You can run the following command to generate the schema from the root of the Supamode project using the `saas` template, which is the most complete template:

```bash
pnpm run generate-schema --template saas --root-account 12222f67-e012-476c-a033-513d4a0e051a
```

The output from the command will be the following:

```
Generating seed from saas...
✓ SQL generated at /Users/giancarlo/Code/supadmin-turbo/apps/app/supabase/seeds/02-supamode-seed.sql
```

Create a new migration where we can store the Supamode schema in your application project:

```bash
pnpm run --filter web supabase migration new supamode
```

Now, copy the content of all the migrations in the Supamode migrations directory to the newly created migration in your application project. Also include the content of the newly generated seed file to the migration file.

We can now apply the migration to your Supabase project by running the following command:

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

This will apply the migration to your Supabase project and create the `supamode` schema and install the tables and functions needed to run Supamode.

You can now sign in into Supamode using the root account you provided to the `generate-schema` command.

Once you've signed in, you can start using the Supamode CMS. The first thing you want to do is to sync the managed tables from your Supabase project to the Supamode project.

```sql
select supamode.sync_managed_tables('public');
```

This will sync the managed tables from your Supabase project to the Supamode project. After refreshing the page, you should be able to see the tables in the Supamode CMS:

{% img src="/images/posts/supamode-blog-tables.webp" width="2970" height="2028" alt="Supamode Blog Tables" /%}

## Configuring the Supamode CMS tables

While Supamode attempts to infer the best settings for the tables, it's recommended to configure the tables to get the best user experience.

For example, for the `posts` table, you can configure the following:
- Display Name: Posts
- Description: Posts table
- Display Format: {title}

Furthermore, it's important to set the correct data types for the columns. For example, the `content` column should be set to `Markdown`. This will automatically render the content as Markdown in the Supamode CMS and allow a rich editing experience for the content.

{% img src="/images/posts/blog-demo-markdown-config.webp" width="1920" height="1080" alt="Supamode Blog Post Content" /%}

When you navigate to the `posts` table, you should be able to see the posts in the list view:

{% img src="/images/posts/blog-demo-post-list.webp" width="1920" height="1080" alt="Supamode Blog Posts List" /%}

You can also navigate to the detail view of a post by clicking on the post title:

{% img src="/images/posts/blog-demo-post-detail.webp" width="1920" height="1080" alt="Supamode Blog Post Detail" /%}

And customize the Markdown content with a rich editor:

{% img src="/images/posts/blog-demo-markdown-editor.webp" width="1920" height="1080" alt="Supamode Blog Markdown Editor" /%}

You now have a fully functional CMS for your blog! 🎉 

# 6. Going Futher: Advanced permissions

While the blog is now fully functional, you may want to add more advanced permissions to the blog. For example, you may want to create a specific role for editors that can only read/write the posts.

## Creating a new permission

To create a new permission, you can navigate to the "Permissions" tab at '/settings/permissions?tab=permissions' and click on the **New Permission** button at the top right of the page:

{% img src="/images/posts/blog-demo-create-permission.webp" width="1920" height="1080" alt="Supamode Blog Create Permission" /%}

When you open the dialog, you can see the following fields:

- **Name**: The name of the permission.
- **Description**: The description of the permission.
- **Type**: The type of the permission (System or Data)
- **Scope**: The scope of the permission.
- **Action**: The action of the permission.

## Creating a new role

To create a new role, you can navigate to the "Roles" tab at '/settings/permissions?tab=roles' and click on the **New Role** button at the top right of the page:

{% img src="/images/posts/blog-demo-create-role.webp" width="1920" height="1080" alt="Supamode Blog Create Role" /%}

## Assigning the permission to the role

You can then assign the permission to the role:

{% img src="/images/posts/blog-demo-assign-permission.webp" width="1920" height="1080" alt="Supamode Blog Assign Permission" /%}

You can now invite your team members and assign the role to them - so that they can only read/write the posts.

Of course, it makes sense to add more permissions and roles to this role, such as the ability to create categories, tags, etc.

# 7. Conclusion

You now **have the full blueprint for launching a production-ready Supabase blog with Next.js**: a normalized schema with RLS, type-safe queries living in a shared loader, reusable UI components that render instantly on the server, and a **Supamode-powered CMS** that lets the team ship content without touching SQL.

Because everything sits on top of the MakerKit starter, you inherit authentication, theming, and deployment best practices from day one.

From here, you can extend the workflow however your product needs—add scheduled publishing, wire up analytics events, or expose the same content through an API for mobile apps. 

If you want a head start, clone the MakerKit Lite Starter, apply the migrations in this tutorial, and turn on Supamode to give your writers a **world-class CMS editing experience on top of Supabase**.

## Resources

- [MakerKit Lite Starter - an Open Source Next.js SaaS Starter Kit](https://github.com/makerkit/nextjs-saas-starter-kit-lite)
- [Supamode, the Supabase CMS - a CMS for your Supabase project](/supabase-cms)
- [Supamode documentation](/docs/supamode)
