Build a Blog
Build a complete blogging platform with posts, categories, a published filter, and a custom frontend — all in under 30 minutes. You will use Grit's code generator, customize Go handlers, define admin resources, and wire up a Next.js page to display published articles.
Prerequisites
- ✓Go 1.21+ installed
- ✓Node.js 18+ and pnpm installed
- ✓Docker and Docker Compose installed
- ✓Grit CLI installed globally (go install github.com/MUKE-coder/grit/cmd/grit@latest)
Create the project
Scaffold a new Grit monorepo called myblog. This creates the Go API, Next.js web app, admin panel, shared package, and Docker configuration in one shot.
Grit prints an ASCII art logo, creates the folder structure, initializes go.mod, runs pnpm install, and prints the next steps. Your project is ready.
Start Docker services
Spin up PostgreSQL, Redis, MinIO (local S3), and Mailhog. These run in the background and persist data across restarts.
Generate the Post resource
Use the code generator to create a full-stack Post resource. This generates the Go model, handler, service, Zod schema, TypeScript types, React Query hooks, and admin page — all wired together.
The generator creates these files:
apps/api/internal/models/post.go # GORM model apps/api/internal/handlers/post.go # CRUD handler apps/api/internal/services/post.go # Business logic packages/shared/schemas/post.ts # Zod validation packages/shared/types/post.ts # TypeScript types apps/web/hooks/use-posts.ts # React Query hooks (web) apps/admin/hooks/use-posts.ts # React Query hooks (admin) apps/admin/app/resources/posts/page.tsx # Admin page apps/admin/resources/posts.ts # Resource definition
Here is the generated Go model:
package models
import (
"time"
"gorm.io/gorm"
)
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255;not null" json:"title" binding:"required"`
Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`
Content string `gorm:"type:text" json:"content"`
Excerpt string `gorm:"type:text" json:"excerpt"`
Published bool `gorm:"default:false" json:"published"`
Views int `gorm:"default:0" json:"views"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}Generate the Category resource
Every blog needs categories. Generate a Category resource with a name, slug, and description.
The generated Category model:
package models
import (
"time"
"gorm.io/gorm"
)
type Category struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255;uniqueIndex;not null" json:"name" binding:"required"`
Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`
Description string `gorm:"type:text" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}Add a relationship — Post belongs to Category
A post should belong to a category. Open the Post model and add a CategoryID foreign key and a Category relation field. Also add a Posts slice to the Category model for the inverse relationship.
package models
import (
"time"
"gorm.io/gorm"
)
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255;not null" json:"title" binding:"required"`
Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`
Content string `gorm:"type:text" json:"content"`
Excerpt string `gorm:"type:text" json:"excerpt"`
Published bool `gorm:"default:false" json:"published"`
Views int `gorm:"default:0" json:"views"`
CategoryID uint `gorm:"index;not null" json:"category_id" binding:"required"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}package models
import (
"time"
"gorm.io/gorm"
)
type Category struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255;uniqueIndex;not null" json:"name" binding:"required"`
Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`
Description string `gorm:"type:text" json:"description"`
Posts []Post `gorm:"foreignKey:CategoryID" json:"posts,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}Now sync the types to TypeScript so the frontend knows about the relationship:
The grit sync command reads every Go model in internal/models/, parses the structs with Go AST, and regenerates the TypeScript types and Zod schemas in packages/shared/. The Post type now includes category_id and an optional category object.
Customize the Post handler to preload Category
By default, the generated handler does not preload relationships. Open the Post handler and add a Preload("Category") call so that every post response includes its category data.
func (s *PostService) GetAll(page, pageSize int, sort, order, search string) ([]models.Post, int64, error) {
var posts []models.Post
var total int64
query := s.db.Model(&models.Post{})
// Search across title and excerpt
if search != "" {
query = query.Where("title ILIKE ? OR excerpt ILIKE ?",
"%"+search+"%", "%"+search+"%")
}
// Count total before pagination
query.Count(&total)
// Sorting
if sort != "" {
direction := "ASC"
if order == "desc" {
direction = "DESC"
}
query = query.Order(sort + " " + direction)
} else {
query = query.Order("created_at DESC")
}
// Preload the Category relationship
query = query.Preload("Category")
// Pagination
offset := (page - 1) * pageSize
result := query.Offset(offset).Limit(pageSize).Find(&posts)
return posts, total, result.Error
}
func (s *PostService) GetByID(id uint) (*models.Post, error) {
var post models.Post
// Preload Category when fetching a single post
result := s.db.Preload("Category").First(&post, id)
if result.Error != nil {
return nil, result.Error
}
return &post, nil
}Now every API response for posts includes the nested category object with its name, slug, and description.
Add a custom endpoint — published posts only
The public frontend should only show published posts. Add a new handler method and register it as a public route (no auth required).
// GetPublished returns only published posts for the public frontend.
func (h *PostHandler) GetPublished(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
var posts []models.Post
var total int64
query := h.db.Model(&models.Post{}).Where("published = ?", true)
query.Count(&total)
query.Preload("Category").
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&posts)
pages := int(total) / pageSize
if int(total)%pageSize != 0 {
pages++
}
c.JSON(http.StatusOK, gin.H{
"data": posts,
"meta": gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"pages": pages,
},
})
}Register the new route in routes.go. Place it outside the auth middleware group so anyone can access it:
// Public routes (no authentication required)
public := router.Group("/api")
{
// ... existing public routes (auth, health) ...
// Published blog posts — public access
public.GET("/posts/published", postHandler.GetPublished)
}Customize the admin resource definition
The generated admin resource is functional but generic. Let's make it blog-specific by adding a category filter, a published badge, relative dates, a view count column, and a category selector in the form.
import { defineResource } from '@grit/admin'
export default defineResource({
name: 'Post',
endpoint: '/api/posts',
icon: 'FileText',
table: {
columns: [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'title', label: 'Title', sortable: true, searchable: true },
{ key: 'slug', label: 'Slug', sortable: true },
{ key: 'category.name', label: 'Category', relation: 'category' },
{ key: 'published', label: 'Status', badge: {
true: { color: 'green', label: 'Published' },
false: { color: 'yellow', label: 'Draft' },
}},
{ key: 'views', label: 'Views', sortable: true },
{ key: 'created_at', label: 'Created', format: 'relative' },
],
filters: [
{ key: 'published', type: 'select', options: [
{ label: 'Published', value: 'true' },
{ label: 'Draft', value: 'false' },
]},
{ key: 'category_id', type: 'select', resource: 'categories',
displayKey: 'name', label: 'Category' },
{ key: 'created_at', type: 'date-range' },
],
actions: ['create', 'edit', 'delete', 'export'],
bulkActions: ['delete', 'export'],
},
form: {
fields: [
{ key: 'title', label: 'Title', type: 'text', required: true },
{ key: 'slug', label: 'Slug', type: 'text', required: true },
{ key: 'category_id', label: 'Category', type: 'relation',
resource: 'categories', displayKey: 'name', required: true },
{ key: 'excerpt', label: 'Excerpt', type: 'textarea' },
{ key: 'content', label: 'Content', type: 'richtext' },
{ key: 'published', label: 'Published', type: 'toggle', default: false },
],
},
})Update the web frontend to display posts
Now build the public-facing blog page. Create a custom React Query hook that calls the /api/posts/published endpoint, then build a page component that lists the posts with their categories.
First, add a hook for the published posts endpoint:
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
import type { Post } from '@shared/types/post'
import type { PaginatedResponse } from '@shared/types/api'
interface UsePublishedPostsOptions {
page?: number
pageSize?: number
}
export function usePublishedPosts({ page = 1, pageSize = 10 }: UsePublishedPostsOptions = {}) {
return useQuery<PaginatedResponse<Post>>({
queryKey: ['posts', 'published', page, pageSize],
queryFn: async () => {
const { data } = await apiClient.get('/api/posts/published', {
params: { page, page_size: pageSize },
})
return data
},
})
}Next, create the blog listing page:
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePublishedPosts } from '@/hooks/use-published-posts'
export default function BlogPage() {
const [page, setPage] = useState(1)
const { data, isLoading } = usePublishedPosts({ page, pageSize: 12 })
if (isLoading) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Blog</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/40 bg-card/50 p-6 animate-pulse">
<div className="h-4 w-20 rounded bg-accent/30 mb-3" />
<div className="h-6 w-3/4 rounded bg-accent/30 mb-2" />
<div className="h-4 w-full rounded bg-accent/30" />
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Blog</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{data?.data.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group rounded-xl border border-border/40 bg-card/50 p-6
hover:border-primary/30 hover:bg-card/80 transition-all"
>
{post.category && (
<span className="text-xs font-mono text-primary/70 mb-2 block">
{post.category.name}
</span>
)}
<h2 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors">
{post.title}
</h2>
{post.excerpt && (
<p className="text-sm text-muted-foreground/70 line-clamp-2">
{post.excerpt}
</p>
)}
<p className="text-xs text-muted-foreground/50 mt-3">
{new Date(post.created_at).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
})}
</p>
</Link>
))}
</div>
{/* Pagination */}
{data?.meta && data.meta.pages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm rounded-md border border-border/40
hover:bg-accent/50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {data.meta.pages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.meta.pages, p + 1))}
disabled={page === data.meta.pages}
className="px-3 py-1.5 text-sm rounded-md border border-border/40
hover:bg-accent/50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</div>
)
}Finally, create the single post page to display a full article:
'use client'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
import type { Post } from '@shared/types/post'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
export default function BlogPostPage() {
const params = useParams()
const slug = params.slug as string
const { data: post, isLoading } = useQuery<Post>({
queryKey: ['posts', 'slug', slug],
queryFn: async () => {
const { data } = await apiClient.get(`/api/posts/published?slug=${slug}`)
// Find the matching post from the published list
return data.data[0]
},
})
if (isLoading) {
return (
<div className="max-w-2xl mx-auto animate-pulse space-y-4">
<div className="h-8 w-3/4 rounded bg-accent/30" />
<div className="h-4 w-1/4 rounded bg-accent/30" />
<div className="space-y-2 pt-6">
<div className="h-4 w-full rounded bg-accent/30" />
<div className="h-4 w-5/6 rounded bg-accent/30" />
<div className="h-4 w-4/6 rounded bg-accent/30" />
</div>
</div>
)
}
if (!post) {
return (
<div className="max-w-2xl mx-auto text-center py-20">
<h1 className="text-2xl font-bold mb-2">Post not found</h1>
<p className="text-muted-foreground mb-4">This post may have been unpublished or deleted.</p>
<Link href="/blog" className="text-primary hover:underline">Back to blog</Link>
</div>
)
}
return (
<article className="max-w-2xl mx-auto">
<Link href="/blog" className="flex items-center gap-1.5 text-sm text-muted-foreground/60
hover:text-foreground transition-colors mb-6">
<ArrowLeft className="h-3.5 w-3.5" />
Back to blog
</Link>
{post.category && (
<span className="text-xs font-mono text-primary/70 mb-3 block">
{post.category.name}
</span>
)}
<h1 className="text-3xl font-bold tracking-tight mb-3">{post.title}</h1>
<p className="text-sm text-muted-foreground/60 mb-8">
{new Date(post.created_at).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
})}
{' '}—{' '}{post.views} views
</p>
<div className="prose prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}Run and test everything
Start the Go API, the web app, and the admin panel in development mode. Grit uses Turborepo to run all services concurrently.
Open these URLs in your browser:
http://localhost:3000— Web frontend — register and see the bloghttp://localhost:3001— Admin panel — manage posts and categorieshttp://localhost:8080/studio— GORM Studio — browse your databasehttp://localhost:8080/api/posts/published— Published posts API
What you've built
- ✓A full-stack blog with Go API and Next.js frontend
- ✓Post and Category resources generated with a single CLI command each
- ✓A BelongsTo relationship between Posts and Categories
- ✓A custom public endpoint that returns only published posts
- ✓An admin panel with sortable tables, category filters, and status badges
- ✓A blog listing page with pagination and article detail pages
- ✓Type-safe data fetching with React Query and shared Zod schemas
- ✓Docker-based PostgreSQL, Redis, MinIO, and Mailhog running locally
- ✓GORM Studio for visual database browsing
Next steps
Now that you have a working blog, here are some ideas to extend it:
- Add tags — generate a
Tagresource and create a many-to-many relationship with posts using a join table. - Markdown rendering — store content as Markdown and render it on the frontend with
react-markdown. - Image uploads — use the Grit storage service to upload cover images for each post.
- RSS feed — add a custom Go handler that returns an XML RSS feed of published posts.
- SEO metadata — use Next.js
generateMetadatato set page titles and descriptions dynamically.