Tutorial

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)
1

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.

terminal
$ grit new myblog
$ cd myblog

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.

2

Start Docker services

Spin up PostgreSQL, Redis, MinIO (local S3), and Mailhog. These run in the background and persist data across restarts.

terminal
$ docker compose up -d
3

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.

terminal
$ grit generate resource Post --fields "title:string,slug:string:unique,content:text,excerpt:text,published:bool,views:int"

The generator creates these files:

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

apps/api/internal/models/post.go
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"`
}
4

Generate the Category resource

Every blog needs categories. Generate a Category resource with a name, slug, and description.

terminal
$ grit generate resource Category --fields "name:string:unique,slug:string:unique,description:text"

The generated Category model:

apps/api/internal/models/category.go
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"`
}
5

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.

apps/api/internal/models/post.go
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"`
}
apps/api/internal/models/category.go
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:

terminal
$ grit sync

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.

6

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.

apps/api/internal/services/post.go — GetAll method
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.

7

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

apps/api/internal/handlers/post.go — add this method
// 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:

apps/api/internal/routes/routes.go — add this route
// 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)
}
8

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.

apps/admin/resources/posts.ts
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 },
],
},
})
9

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:

apps/web/hooks/use-published-posts.ts
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:

apps/web/app/(dashboard)/blog/page.tsx
'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:

apps/web/app/(dashboard)/blog/[slug]/page.tsx
'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',
})}
{' '}&mdash;{' '}{post.views} views
</p>
<div className="prose prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
10

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.

terminal
$ grit dev

Open these URLs in your browser:

  • http://localhost:3000Web frontend — register and see the blog
  • http://localhost:3001Admin panel — manage posts and categories
  • http://localhost:8080/studioGORM Studio — browse your database
  • http://localhost:8080/api/posts/publishedPublished 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 Tag resource 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 generateMetadata to set page titles and descriptions dynamically.