Building a Blog and CMS with Go, MySQL, and HTMX
engineering / go / htmx

Building a Blog and CMS with Go, MySQL, and HTMX

October 27, 2023, by Kevin

I wanted to replace my old Ghost tech blog and write my own with its own database to practice building full stack products in my free time. I really like the simplicity of only Typescript for full stack projects, but I had been learning Go for months, and I recently gave into the HTMX hype thanks to Primagen, a senior software engineer at Netflix and streamer. I didn't want to setup a separate frontend, also, I use the MVC architecture at work with HTML::Template and Perl, so going with Go felt like the right choice.

The Tech Stack

I use Docker, and I like to deploy using a VPS. The database is MySQL, nothing fancy. As far as frameworks in Go go, the standard library does most of the work but I am using the Gorilla/Mux router and Air for hot reload. No MySQL ORM, just the standard driver because I like to write my own SQL queries :)

Setting up the database

I initially had the database and the application setup with Docker compose, but database providers have very generous free tiers now days, so I switched to PlanetScale for a bit of extra redundancy, and for the branched schemas, that has been a very neat feature when making modifications. The schema as it stands is below.

CREATE TABLE `users` (
	`id` int NOT NULL AUTO_INCREMENT,
	`name` varchar(64) NOT NULL,
	`email` varchar(320) NOT NULL,
	`password` varchar(255) NOT NULL,
	`about` varchar(64),
	`contact` text,
	PRIMARY KEY (`id`),
	UNIQUE KEY `email` (`email`)
) ENGINE InnoDB,
  CHARSET utf8mb4,
  COLLATE utf8mb4_0900_ai_ci;

  CREATE TABLE `projects` (
	`id` int NOT NULL AUTO_INCREMENT,
	`title` varchar(255) NOT NULL,
	`description` varchar(255) NOT NULL,
	`url` varchar(255) NOT NULL,
	`image` varchar(255),
	`classes` varchar(255),
	`author` int NOT NULL,
	PRIMARY KEY (`id`),
	KEY `author` (`author`)
) ENGINE InnoDB,
  CHARSET utf8mb4,
  COLLATE utf8mb4_0900_ai_ci;

  CREATE TABLE `articles` (
	`id` int NOT NULL AUTO_INCREMENT,
	`image` varchar(255),
	`slug` varchar(255) NOT NULL,
	`title` varchar(60) NOT NULL,
	`content` text NOT NULL,
	`author` int NOT NULL,
	`created_at` datetime NOT NULL,
	`is_draft` tinyint(1) NOT NULL DEFAULT '0',
	PRIMARY KEY (`id`),
	UNIQUE KEY `slug` (`slug`),
	KEY `author` (`author`)
) ENGINE InnoDB,
  CHARSET utf8mb4,
  COLLATE utf8mb4_0900_ai_ci;

  CREATE TABLE `tags` (
	`tag_id` int NOT NULL AUTO_INCREMENT,
	`tag_name` varchar(50),
	PRIMARY KEY (`tag_id`),
	UNIQUE KEY `tag_name` (`tag_name`)
) ENGINE InnoDB,
  CHARSET utf8mb4,
  COLLATE utf8mb4_0900_ai_ci;

  CREATE TABLE `article_tags` (
	`article_id` int NOT NULL,
	`tag_id` int NOT NULL,
	PRIMARY KEY (`article_id`, `tag_id`)
) ENGINE InnoDB,
  CHARSET utf8mb4,
  COLLATE utf8mb4_0900_ai_ci;

Most of these are one-to-many relationships, except the tags. Tags and articles use a many-to-many relationship, so it needs its own table to keep track of the relationships, per the rules of database normalization.

The Go Backend

The main project directories and key components for this project are models/, views/, controllers/ and routes/ but I also added /utils for helper function and /static for static files like css. The initial project should look something like this.

app
└───models
│   │   models.go
└───views
    │   views.go
    │   index.html
└───controllers
    │   controllers.go
└───utils
    │   utils.go
└───static
    │   main.css
│   README.md
│   main.go    

If you want to setup hot reload, you can follow instructions here: air

Models

Here's an overview of the models, the articles don't have a Tag field in the database, but I added one in code, as it is easier to assign the tags to the articles when requesting multiple articles from the templates.

//Full code: https://github.com/kevingil/blog/tree/main/app/models

package models

import (
	"database/sql"
	"fmt"
	"log"
	"sort"
	"time"
)

// User is a model for users.
type User struct {
	ID       int
	Name     string
	Email    string
	Password []byte
	About    string
	Contact  string
}

// Projects is a model for home page projects.
type Project struct {
	ID          int
	Title       string
	Description string
	Url         string
	Image       string
	Classes     string
}

// Article is a model for articles.
type Article struct {
	ID        int
	Image     string
	Slug      string
	Title     string
	Content   string
	Author    User
	CreatedAt time.Time
	IsDraft   int
	Tags      []*Tag
}

type Tag struct {
	ID   int
	Name string
}

var (
	// Db is a database connection.
	Db *sql.DB

	// Err is an error returned.
	Err error
)

// FindArticle is to print an article.
func FindArticle(slug string) *Article {
	rows, err := Db.Query(`SELECT articles.id, articles.image, articles.title, articles.content, users.name, articles.created_at FROM articles JOIN users ON users.id = articles.author WHERE slug = ?`, slug)
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	var createdAt []byte
	user := &User{}
	article := &Article{}

	for rows.Next() {
		err = rows.Scan(&article.ID, &article.Image, &article.Title, &article.Content, &user.Name, &createdAt)
		if err != nil {
			log.Fatal(err)
		}
		parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
		if err != nil {
			log.Fatal(err)
		}
		article.CreatedAt = parsedCreatedAt
		article.Author = *user
	}

	return article
}

// Articles is a list of all articles.
func Articles() []*Article {
	var articles []*Article

	rows, err := Db.Query(`
    SELECT articles.id, articles.image, articles.slug, articles.title, articles.content, users.name, articles.created_at
    FROM articles
    JOIN users ON users.id = articles.author
    WHERE articles.is_draft = 0
    ORDER BY articles.created_at DESC 
`)
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	for rows.Next() {
		var (
			id        int
			image     string
			slug      string
			title     string
			content   string
			author    string
			createdAt []byte
		)
		err = rows.Scan(&id, &image, &slug, &title, &content, &author, &createdAt)
		if err != nil {
			print("Error finding articles")
			log.Fatal(err)
		}
		parsedCreatedAt, err := time.Parse("2006-01-02 15:04:05", string(createdAt))
		if err != nil {
			log.Fatal(err)
		}
		user := User{
			Name: author,
		}
		articles = append(articles, &Article{id, image, slug, title, content, user, parsedCreatedAt, 0, nil})
	}

	return articles
}
// CreateArticle creates an article.
func (user User) CreateArticle(article *Article) {
	_, err := Db.Exec(
		"INSERT INTO articles(image, slug, title, content, author, created_at, is_draft) VALUES(?, ?, ?, ?, ?, ?, ?)",
		article.Image,
		article.Slug,
		article.Title,
		article.Content,
		article.Author.ID,
		article.CreatedAt,
		article.IsDraft,
	)
	if err != nil {
		log.Fatal(err)
	}
}

// UpdateArticle updates an article.
func (user User) UpdateArticle(article *Article) {
	_, err := Db.Exec(
		"UPDATE articles SET image = ?, slug = ?, title = ?, content = ?, created_at = ?, is_draft = ? WHERE id = ? AND author = ?",
		article.Image,
		article.Slug,
		article.Title,
		article.Content,
		article.CreatedAt,
		article.IsDraft,
		article.ID,
		user.ID,
	)
	if err != nil {
		log.Fatal(err)
	}
}

// DeleteArticle deletes an article.
func (user User) DeleteArticle(article *Article) {
	_, err := Db.Exec(
		"DELETE FROM articles WHERE id = ? AND author = ?",
		article.ID,
		user.ID,
	)
	if err != nil {
		log.Fatal(err)
	}
}
Views

Handling the views with the default templating library is very simple, I also added some helper functions that were too small to add to utils.

package views

import (
	"path/filepath"
	"regexp"
	"text/template"
	"time"

	"bytes"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
)

// Tmpl is a template.
var Tmpl *template.Template

func date(t *time.Time) string {
	return t.Local().Format("January 2, 2006 15:04:05")
}

func shortDate(t *time.Time) string {
	return t.Local().Format("January 2, 2006")
}

func mdToHTML(content string) string {
	md := goldmark.New(
		goldmark.WithExtensions(extension.GFM),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(
			html.WithHardWraps(),
			html.WithXHTML(),
			html.WithUnsafe(),
		),
	)
	c := []byte(content)
	var buf bytes.Buffer
	if err := md.Convert(c, &buf); err != nil {
		panic(err)
	}

	return buf.String()
}

func truncate(s string) string {

	re := regexp.MustCompile("<[^>]*>")
	plainText := re.ReplaceAllString(s, "")

	result := plainText
	if len(plainText) > 120 {
		result = plainText[:120] + ".."
	}

	return result
}

func draft(i int) bool {
	if i == 1 {
		return true
	}
	return false
}

var functions = template.FuncMap{
	"date":      date,
	"shortDate": shortDate,
	"truncate":  truncate,
	"mdToHTML":  mdToHTML,
	"draft":     draft,
	"sub": func(a, b int) int {
		return a - b
	},
}

func init() {
	Tmpl = template.Must(template.New("./views/*.gohtml").Funcs(functions).ParseGlob("./views/*.gohtml"))
}

func init() {
	// Direcotries to parse
	dirs := []string{"./views/*.gohtml", "./views/pages/*.gohtml", "./views/forms/*.gohtml", "./views/components/*.gohtml"}

	//Create a new Tmpl from all directories
	Tmpl = template.New("").Funcs(functions)
	for _, dir := range dirs {
		files, err := filepath.Glob(dir)
		if err != nil {
			panic(err)
		}

		for _, file := range files {
			_, err = Tmpl.ParseFiles(file)
			if err != nil {
				panic(err)
			}
		}
	}
}
Controllers

The controllers usually return JSON, but with HTMX we return HTML. Also, we sometimes need to return a partial template , so we check for HX-Request in the HTTP request header.

//Full code: https://github.com/kevingil/blog/tree/main/app/controllers

// Dashboard is a controller for users to list articles.
func Dashboard(w http.ResponseWriter, r *http.Request) {
	permission(w, r)

	cookie := getCookie(r)
	user := Sessions[cookie.Value]
	model := r.URL.Query().Get("edit")
	id, _ := strconv.Atoi(r.URL.Query().Get("id"))
	delete := r.URL.Query().Get("delete")
	layout := "dashboard"

	switch r.Method {
	case http.MethodGet:
		switch model {
		case "article":
			if delete != "" && id != 0 {
				article := &models.Article{
					ID: id,
				}

				user.DeleteArticle(article)

				http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
			} else {
				data.Article = &models.Article{
					Image:   "",
					Title:   "",
					Content: "",
					IsDraft: 0,
				}

				if user != nil && id != 0 {
					data.Article = user.FindArticle(id)
					data.Tags = data.Article.FindTags()
				}

				Hx(w, r, "main_layout", "edit_article", data)

			}
		default:
			if user != nil {
				data.ArticleCount = user.CountArticles()
				data.DraftCount = user.CountDrafts()
				data.Articles = user.FindArticles()
			}

			Hx(w, r, layout, "dashboard_home", data)

		}
	case http.MethodPost:
		switch model {
		case "article":
			if user != nil {
				isDraftStr := r.FormValue("isDraft")
				isDraft, err := strconv.Atoi(isDraftStr)
				if err != nil {
					isDraft = 0
				}
				if id == 0 {
					article := &models.Article{
						Image:     r.FormValue("image"),
						Slug:      slug.Make(r.FormValue("title")),
						Title:     r.FormValue("title"),
						Content:   r.FormValue("content"),
						Author:    *user,
						CreatedAt: time.Now(),
						IsDraft:   isDraft,
					}

					user.CreateArticle(article)
				} else {
					createdAtStr := r.FormValue("createdat")
					createdAt, err := time.Parse("2006-01-02", createdAtStr)
					if err != nil {
						createdAt = time.Now()
					}
					article := &models.Article{
						ID:        id,
						Image:     r.FormValue("image"),
						Slug:      slug.Make(r.FormValue("title")),
						Title:     r.FormValue("title"),
						Content:   r.FormValue("content"),
						CreatedAt: createdAt,
						IsDraft:   isDraft,
					}

					// Handle tags
					rawtags := r.Form["tags"]
					tags := make([]*models.Tag, 0)
					tagNames := strings.Split(rawtags[0], ",")
					for _, tagName := range tagNames {
						trimmedTagName := strings.TrimSpace(tagName)
						if trimmedTagName != "" {
							tag := &models.Tag{
								Name: trimmedTagName,
							}
							tags = append(tags, tag)
						}
					}

					user.UpdateArticle(article)
					article.UpdateTags(tags)
				}
			}

			http.Redirect(w, r, "/dashboard/articles", http.StatusSeeOther)
		}
	}
}

// Hx is a utility function to render a child template wrapped with a specified layout.
func Hx(w http.ResponseWriter, r *http.Request, l string, t string, data Context) {
	var response bytes.Buffer
	var child bytes.Buffer

	if err := views.Tmpl.ExecuteTemplate(&child, t, data); err != nil {
		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	if r.Header.Get("HX-Request") == "true" {
		io.WriteString(w, child.String())

	} else {
		data.View = template.HTML(child.String())
		if err := views.Tmpl.ExecuteTemplate(&response, l, data); err != nil {
			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
			return
		}
		io.WriteString(w, response.String())

	}

}
Routes

Each controller uses a template to send back data and for CRUD operations, we want to send the edit form, and submit that form with POST to make the edits. The way the controllers are set up allows us to make edits to "profile" or "articles" from a single endpoint depending on the HTTP request method we use, keeping the code clean and easy to read.

package routes

import (
	"log"
	"net/http"
	"os"

	"github.com/gorilla/mux"
	"github.com/kevingil/blog/app/controllers"
	"github.com/kevingil/blog/app/services/coffeeapp"
)

func Init() {
	r := mux.NewRouter()

	// Blog pages
	r.HandleFunc("/", controllers.Index)

	// User login, logout, register
	r.HandleFunc("/login", controllers.Login)
	r.HandleFunc("/logout", controllers.Logout)
	r.HandleFunc("/register", controllers.Register)

	// User Dashboard
	r.HandleFunc("/dashboard", controllers.Dashboard)

	// Projects
	// Edit articles, delete, or create new
	r.HandleFunc("/dashboard/articles", controllers.Articles)

	// View posts, preview drafts
	r.HandleFunc("/article/{slug}", controllers.Article)

	// User Profile
	// Edit about me, skills, and projects
	r.HandleFunc("/dashboard/profile", controllers.Profile)

	// Resume Edit
	r.HandleFunc("/contact", controllers.Contact)
	r.HandleFunc("/dashboard/resume", controllers.Resume)

	//Files
	r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
	log.Printf("Your app is running on port %s.", os.Getenv("PORT"))
	log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), r))
}

Requests with HTMX

The magic of HTMX, is that hx-get and hx-swap, are really just Ajax calls that fetch server side rented HTML, just like React Server Components.

The code snippet below generates a list of blog posts from the “Articles” model I wrote in Go. When clicked, the controller returns the rendered article and changes the URL route accordingly, without rendering the entire page.

<!-- Posts Section -->
<p class="text-lg font-semibold text-zinc-900 py-6">Blog</p>
<div class="text-zinc-800 shadow-66">
    {{if not .Articles}}
    <p>No articles.</p>
    {{else}}
    {{range .Articles}}
        <a hx-get="/post/{{.Slug}}" hx-boost="true" hx-swap="innerHTML transition:true"
        hx-target="#container" hx-push-url="true" class="p-4 w-full flex flex-col mb-4 w-fill flex text-zinc-800 bg-white/75 hover:bg-white rounded-lg">
            <h2 class="text-xl">{{.Title}}</h2>
            <p class="text-xs font-semibold">{{.CreatedAt.Format "2006-01-02"}}</p>
        </a>
    {{end}}
    {{end}}
</div>

Some drawbacks with transitions

Transition are made easy. You just need to add the transition class to the target element, then you activate them with hx-swap="innerHTML transition:true". That will tell HTMX to use the transition on the target element. However, they DO NOT work with Safari/iPhone, as the CSS view-transition API is not yet supported.

Screenshots

The latest version also includes an updated editor with Alpine.js, the code can be found at github.com/kevingil/blog.

Dashboard

main

Post editor

editor

engineering go htmx