Courses/Blog & CMS
Standalone Course~30 min14 challenges

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.

CMS (Content Management System): A system for creating, managing, and publishing content. It separates content from presentation — you write in the admin panel, and the public site renders it beautifully. WordPress, Ghost, and Strapi are all CMSes. We're building one with Grit.

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
Grit's code generator handles 80% of the CMS work. You define the data model, generate resources, and get full CRUD with admin panel, API, and frontend pages — all in minutes.
1

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:

Resource: Category
grit generate resource Category \
  name:string:unique \
  slug:slug:name \
  description:text:optional
Resource: Tag
grit generate resource Tag \
  name:string:unique \
  slug:slug:name
Resource: Post
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
Resource: Comment
grit generate resource Comment \
  content:text \
  author_name:string \
  author_email:string \
  post_id:belongs_to:Post \
  approved:bool
Order matters. Generate Category and Tag before Post, because Post references them with belongs_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
2

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.

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

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

Use the API docs at /docs for quick data entry. You can POST JSON directly without navigating the admin UI. Great for seeding large amounts of test data.
3

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.

API Queries
# 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+started

The response includes pagination metadata:

API Response
{
  "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
  }
}
The published=true filter is critical. Without it, draft posts would appear on the public blog. Always filter by published status on public-facing endpoints.
4

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.

Fetch Post by Slug
# 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 timestamps

The 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
5

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.

Submit a Comment
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 approved

The public API only returns approved comments:

Fetch 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 }
The admin panel's comment list should highlight unapproved comments. Think of it as a moderation queue — new comments appear at the top, waiting for approval. One click to approve, one click to delete spam.
6

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.

Slug: A URL-friendly version of a string. Lowercase, hyphens instead of spaces, no special characters. "My First Blog Post!" becomes 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
Next.js Metadata (Simplified)
// 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.slug
7

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

RSS (Really Simple Syndication): An XML format for distributing content updates. Feed readers poll RSS URLs periodically to show new posts. Most blogs, podcasts, and news sites publish RSS feeds. It's how people follow content without checking websites manually.
RSS Feed Endpoint (Go)
// 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.Name

The XML structure follows the RSS 2.0 specification:

RSS XML Structure
<!-- RSS 2.0 Feed -->
<!-- channel: title, link, description, language -->
<!-- For each published post: -->
<!--   item: title, link, description, pubDate, category, author -->
8

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
The admin panel's DataTable component supports sorting and filtering out of the box. Click column headers to sort, use the filter inputs to narrow results. The generated admin pages already have this wired up.
9

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.

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

Docker Compose includes MinIO for local development — an S3-compatible object store that runs on your machine. In production, switch to Cloudflare R2 or AWS S3 by changing environment variables. No code changes needed.
10

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
11

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.

12

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.

13

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.

14

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.