• Blog
  • Documentation
  • Courses
  • Changelog
  • AI Starters
  • UI Kit
  • FAQ
  • Supamode
    New
  • Pricing

Launch your next SaaS in record time with Makerkit, a React SaaS Boilerplate for Next.js and Supabase.

Makerkit is a product of Makerkit Pte Ltd (registered in the Republic of Singapore)Company Registration No: 202407149CFor support or inquiries, please contact us

About
  • FAQ
  • Contact
  • Verify your Discord
  • Consultation
  • Open Source
  • Become an Affiliate
Product
  • Documentation
  • Blog
  • Changelog
  • UI Blocks
  • Figma UI Kit
  • AI SaaS Starters
License
  • Activate License
  • Upgrade License
  • Invite Member
Legal
  • Terms of License

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

Oct 13, 2025

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

next
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, 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 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:

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.

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:

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:

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:

pnpm dev

Then visit 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:

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.

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

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

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.

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.

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

pnpm --filter web supabase migrations up

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

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:

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

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.

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

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:

pnpm --filter web add react-markdown

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

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.

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.

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.

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.

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

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:

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:

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:

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.

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:

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.

Supamode Blog Post Content

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

Supamode Blog Posts List

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

Supamode Blog Post Detail

And customize the Markdown content with a rich editor:

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:

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:

Supamode Blog Create Role

Assigning the permission to the role

You can then assign the permission to the role:

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
  • Supamode, the Supabase CMS - a CMS for your Supabase project
  • Supamode documentation
Some other posts you might like...
Sep 25, 2025Mastering AI-Driven Development: Claude Code & Makerkit Best PracticesA comprehensive guide to building production-ready SaaS features using Claude Code's intelligent agents and Makerkit's PRD functionality
Jun 9, 2025Build a SaaS with Claude CodeThis is a step-by-step guide to building an AI Content Repurposer SaaS by vibe-coding with Claude Code and Makerkit.
Apr 24, 2025The Ultimate Guide to Secure API Key Management in Supabase ProjectsLearn how to build a secure, production-grade API key system in Supabase with PostgreSQL roles, Row Level Security, and scope-based permissions. Complete with code examples.
Apr 23, 2025Next.js Security: A Comprehensive Guide how to secure your Next.js applicationA comprehensive guide to securing your Next.js application, focusing on practical strategies to protect your application and user data from common vulnerabilities.