Build a Blog & CMS: Complete Content Management System
Build a full-featured blog with categories, tags, rich text posts, comments with moderation, SEO-friendly slugs, and a media library. You'll scaffold the project, generate 4 resources, and wire up a complete content management workflow.
What is a CMS?
If you've ever used WordPress, you've used a CMS. A CMS lets you create, edit, organize, and publish content without writing code every time. The admin panel is where you manage everything. The public site is what readers see. We're building both.
Every CMS needs these core pieces:
- • Content types — posts, pages, categories, tags
- • Rich text editor — write with headings, bold, lists, images, code blocks
- • Media library — upload and manage images and files
- • User roles — admins manage content, readers view it
- • SEO — slugs, meta descriptions, Open Graph tags
Challenge: Name 3 Popular CMS Platforms
Name 3 popular CMS platforms. For each one, describe what kind of content it manages and who its typical users are. Think beyond just WordPress — consider headless CMSes, e-commerce platforms, and documentation tools.
Plan the Data Model
Before writing any code, plan your data model. A blog CMS needs 4 resources that work together: categories organize posts, tags add cross-cutting labels, posts hold the content, and comments let readers respond.
Here's the complete data model:
grit generate resource Category \
name:string:unique \
slug:slug:name \
description:text:optionalgrit generate resource Tag \
name:string:unique \
slug:slug:namegrit generate resource Post \
title:string \
slug:slug:title \
content:richtext \
excerpt:text:optional \
published:bool \
featured_image:string:optional \
author_id:belongs_to:User \
category_id:belongs_to:Category \
tag_ids:many_to_many:Taggrit generate resource Comment \
content:text \
author_name:string \
author_email:string \
post_id:belongs_to:Post \
approved:boolbelongs_to and many_to_many. Generate Comment last since it references Post.Notice the field types:
- •
slug:slug:name— auto-generates a URL-friendly slug from the name field - •
content:richtext— renders a rich text editor in the admin panel - •
belongs_to:User— creates a foreign key relationship with automatic joins - •
many_to_many:Tag— creates a join table so posts can have multiple tags
Challenge: Generate All 4 Resources
Run all 4 generate commands in order: Category, Tag, Post, Comment. Check that each resource creates the expected files: model, service, handler, Zod schema, TypeScript types, and admin page. How many files did Grit generate in total?
Scaffold the Project
Start by scaffolding a fresh Grit project with the triple architecture — API, web frontend, and admin panel. Then generate all resources and seed test data.
# Scaffold the project
grit new myblog --triple --next
# Start infrastructure
cd myblog
docker compose up -d
# Generate resources (in order)
grit generate resource Category name:string:unique slug:slug:name description:text:optional
grit generate resource Tag name:string:unique slug:slug:name
grit generate resource Post title:string slug:slug:title content:richtext excerpt:text:optional published:bool featured_image:string:optional author_id:belongs_to:User category_id:belongs_to:Category tag_ids:many_to_many:Tag
grit generate resource Comment content:text author_name:string author_email:string post_id:belongs_to:Post approved:bool
# Start the API (runs migrations automatically)
cd apps/api && go run cmd/server/main.goOnce the API is running, open the admin panel and start creating content. Build a realistic dataset — real blogs have many categories, tags spread across posts, and a mix of published and draft posts.
/docs for quick data entry. You can POST JSON directly without navigating the admin UI. Great for seeding large amounts of test data.Challenge: Create Seed Data
Create 5 categories (e.g., Technology, Design, Business, Lifestyle, Tutorials), 10 tags (e.g., go, react, api, tutorial, beginner, advanced, tips, tools, review, guide), and 20 blog posts spread across different categories with various tags. Make 12 published and 8 drafts.
The Blog List Page
The public blog page shows published posts with filtering and pagination. The API already supports all of this — Grit generates list endpoints with built-in query parameters for filtering, sorting, and pagination.
# List published posts (page 1, 10 per page)
GET /api/posts?published=true&page=1&page_size=10
# Filter by category
GET /api/posts?published=true&category_id=3
# Filter by tag (many-to-many)
GET /api/posts?published=true&tag_ids=5
# Sort by newest first
GET /api/posts?published=true&sort=created_at&order=desc
# Search by title
GET /api/posts?published=true&search=getting+startedThe response includes pagination metadata:
{
"data": [
{
"id": 1,
"title": "Getting Started with Go",
"slug": "getting-started-with-go",
"excerpt": "Learn the basics of Go programming...",
"published": true,
"category": { "id": 1, "name": "Technology" },
"tags": [
{ "id": 1, "name": "go" },
{ "id": 4, "name": "tutorial" }
],
"created_at": "2026-03-15T10:30:00Z"
}
],
"meta": {
"total": 12,
"page": 1,
"page_size": 10,
"pages": 2
}
}published=true filter is critical. Without it, draft posts would appear on the public blog. Always filter by published status on public-facing endpoints.Challenge: Test the Blog List Endpoint
Open the API docs at /docs and test these queries: (1) List all published posts with page_size=5, (2) Filter by a specific category, (3) Sort by title ascending. How many total published posts does each query return?
The Blog Detail Page
Each blog post has a detail page showing the full content. The slug field makes URLs human-readable: /blog/getting-started-with-go instead of /blog/1.
# Get a single post by slug
GET /api/posts?slug=getting-started-with-go
# The response includes all related data:
# - Author (User) with name and avatar
# - Category with name and slug
# - Tags array
# - Full rich text content
# - Featured image URL
# - Created/updated timestampsThe rich text content field stores HTML generated by the editor. It supports headings, bold, italic, lists, code blocks, images, and links. The frontend renders this HTML directly with proper styling.
A complete blog detail page displays:
- • Title — the post headline, large and prominent
- • Featured image — full-width hero image at the top
- • Meta info — author name, publish date, category badge, reading time
- • Content — the full rich text body
- • Tags — clickable tag badges that link to filtered lists
- • Comments — approved comments with a submission form
Challenge: Create a Rich Content Post
Create a blog post with rich text content — include at least 2 headings, bold text, a bulleted list, and a code block. View it via the API using the slug. Does the HTML content come through correctly?
Comment System
Comments let readers respond to posts. The key design decision: comments require moderation. When someone submits a comment, it's created with approved: false. An admin must approve it before it appears publicly. This prevents spam.
POST /api/comments
Content-Type: application/json
{
"content": "Great article! Really helped me understand Go basics.",
"author_name": "Jane Reader",
"author_email": "jane@example.com",
"post_id": 1,
"approved": false
}
# Response: 201 Created
# The comment exists but is not visible publicly until approvedThe public API only returns approved comments:
# Public: only approved comments for a post
GET /api/comments?post_id=1&approved=true
# Admin: all comments (approved + pending)
GET /api/comments?post_id=1
# Admin: approve a comment
PATCH /api/comments/5
{ "approved": true }Challenge: Test Comment Moderation
Add 5 comments to a post using the API. Approve 3 of them in the admin panel. Then query GET /api/comments?post_id=1&approved=true. How many comments appear? The answer should be 3. If all 5 appear, your filter isn't working.
SEO for Blog Posts
Search engine optimization starts with good URLs. The slug field type automatically generates URL-friendly strings from the title: "Getting Started with Go" becomes getting-started-with-go. No IDs in URLs, no random strings.
my-first-blog-post. Slugs make URLs readable and help search engines understand your content.For a complete SEO setup, each blog post page needs:
- • URL —
/blog/getting-started-with-go(slug-based) - • Title tag — "Getting Started with Go | MyBlog"
- • Meta description — uses the excerpt field (keep under 160 characters)
- • Open Graph tags — og:title, og:description, og:image (featured image)
- • Canonical URL — prevents duplicate content issues
// In your blog/[slug]/page.tsx
// Fetch the post data, then export metadata:
//
// title: post.title + " | MyBlog"
// description: post.excerpt
// openGraph.title: post.title
// openGraph.description: post.excerpt
// openGraph.images: [post.featured_image]
// canonical: "https://myblog.com/blog/" + post.slugChallenge: Check Your Slugs
Look at 5 of your blog posts. Are the slugs URL-friendly? Check for: all lowercase, hyphens instead of spaces, no special characters, no trailing hyphens. Try creating a post with a title that has special characters (like "Go & React: A Guide!") — what does the generated slug look like?
RSS Feed
RSS feeds let readers subscribe to your blog using feed readers like Feedly or Inoreader. An RSS feed is an XML document listing your latest posts with title, link, description, and publish date.
// GET /api/feed/rss
// Returns XML content type
//
// Query published posts, ordered by created_at desc, limit 20
// For each post, include:
// - title: post.Title
// - link: "https://myblog.com/blog/" + post.Slug
// - description: post.Excerpt (or first 200 chars of content)
// - pubDate: post.CreatedAt in RFC 822 format
// - category: post.Category.Name
// - author: post.Author.NameThe XML structure follows the RSS 2.0 specification:
<!-- RSS 2.0 Feed -->
<!-- channel: title, link, description, language -->
<!-- For each published post: -->
<!-- item: title, link, description, pubDate, category, author -->Challenge: Plan Your RSS Feed
Plan an RSS feed for your blog. Which fields from the Post model would you include? How many posts should the feed contain? Should draft posts appear in the feed? Write out the fields you'd map: Post.Title maps to RSS title, Post.Slug maps to RSS link, etc.
Admin Content Management
The admin panel is where content creators spend their time. Grit generates a full admin interface for each resource — but a good CMS needs more than basic CRUD. It needs workflow tools for managing content at scale.
Key admin features for a blog CMS:
- • Post list with filters — filter by Published, Draft, or All. Filter by category. Search by title.
- • Bulk actions — select multiple posts and publish, unpublish, or delete them at once
- • Comment moderation queue — list of pending comments with approve/reject buttons
- • Dashboard stats — total posts, published vs. draft, comments pending, top categories
- • Quick edit — change title, category, or published status without opening the full form
Challenge: Full Admin Workflow
In the admin panel, complete this workflow: (1) Create a new post as a draft, (2) Edit it and add rich text content, (3) Assign a category and 3 tags, (4) Publish it, (5) Unpublish it, (6) Delete it. Verify each step works correctly.
Media Library
Blog posts need images. Grit's file storage system (backed by S3-compatible storage like MinIO, Cloudflare R2, or AWS S3) handles uploads, and the featured image field on posts references uploaded files.
# 1. Upload an image
POST /api/uploads
Content-Type: multipart/form-data
# file: my-hero-image.jpg
# Response: { "data": { "id": 1, "url": "/uploads/abc123.jpg" } }
# 2. Use the URL as featured_image when creating/updating a post
PATCH /api/posts/5
{ "featured_image": "/uploads/abc123.jpg" }The media library in the admin panel shows all uploaded files as a grid of thumbnails. Click an image to copy its URL, then paste it into the featured image field or the rich text editor.
Challenge: Upload and Attach Images
Upload 5 images using the API or admin panel. Use them as featured images for 5 different blog posts. Verify the images appear when you fetch the posts via the API. Check: does the featured_image field contain the correct URL?
Summary
Here's everything you learned in this course:
- A CMS separates content creation (admin) from content display (public site)
- Categories and tags organize content — categories are hierarchical, tags are flat
- The slug field type auto-generates URL-friendly strings for SEO
- Rich text content stores HTML from the editor, rendered directly on the frontend
- Comments with moderation prevent spam — approved:bool controls visibility
- List endpoints support filtering, sorting, pagination, and search out of the box
- RSS feeds distribute your content to feed readers as XML
- File uploads handle featured images via S3-compatible storage
- The admin panel provides content workflow: create, edit, publish, unpublish, delete
- belongs_to and many_to_many relationships connect posts to categories, tags, and users
Challenge: Build the Complete Blog (Part 1)
Create the full dataset: 5 categories, 10 tags, and 20 blog posts. Make 10 posts published and 10 drafts. Assign each post a category and at least 2 tags. Give at least 3 posts featured images.
Challenge: Build the Complete Blog (Part 2)
Add comments to 5 posts — at least 3 comments per post. Approve some, leave others pending. Query the public API to verify only approved comments appear. Check the admin panel's moderation queue.
Challenge: Build the Complete Blog (Part 3)
Test the full public API flow: list published posts with pagination, filter by category, filter by tag, fetch a single post by slug, view approved comments. Every query should return the expected results.
Challenge: Build the Complete Blog (Part 4)
Test the full admin workflow: create a post as draft, write rich text content, upload a featured image, assign category and tags, publish, receive comments, approve comments, verify on the public API. This is the complete CMS cycle — creation to publication to engagement.
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.