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-tutorialcd 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 statuscreate 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 infocreate 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 readcreate 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 onlycreate 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 authorsinsert 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 aboveinsert 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 categoriesinsert 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 postsinsert 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 returnsnull
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.

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.

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:

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.

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

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

And customize the Markdown content with a rich 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:

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:

Assigning the permission to the role
You can then assign the permission to the role:

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.