How I developed a markdown blog in Go and HTMX

What's the M in HTML? Mark..up? Not for me.

Why md, why go?

First of all, if you just want to download and use the blogging platform (or to read along with code), you can find it on GitHub.

When I wanted a blog, I was first looking for an easy, straightforward way to do so. Web wise, I am most familiar with PHP and Go. I missed the ‘NodeJS’ and ‘React’ craze (whatever those frameworks are), but I know looking at projects or tutorials related to those frameworks / stacks make me sick in my mouth, it feels so different to my PHP / Wordpress days. But that’s a skill diff with me, I’m sure they are good frameworks.

But I love PHP

I was part of the jQuery nation, I’m somewhat (pleasantly) surprised to see it still around in 2023!

Enter HTMX. Having a background with PHP, HTMX feels just like my pleasant memories of PHP, but better because Go :).

The first thing I turned to was Wordpress, but honestly, it wouldn’t be a fun challenge, it’s full of bugs and dodgy plugins, and I REALLY can’t be bothered with writing some HTML, setting it up in the Wordpress format, creating databases, managing the back end, blah blah blah.

Outside of using Go for API routing for writing command-and-control servers, I’m unfamiliar with how ‘modern web development’ works, all this templating, routing for your website doesn’t make sense to me. As much as I hate to admit it, Wordpress was a great solution to building large, scalable websites.

Markdown is a beautiful way of writing, especially blogging, without the faff of having to write in HTML because come on, who does that?! Lucky for me, the wonderful Go community have developed markdown to HTML libraries, such as markdown.

I’m actually excited to build this. Lets Go!

Setting up

I’ll cover the basics in case you are new to go, but there are some really good resources on learning Go routing and API routing online, a long time ago I actually used TechWithTim, he has a really good video on Gin.

  1. Download Go
  2. Install Go
  3. Add Go to your $PATH
  4. Add Go extension to your IDE / editor of choice.
  5. Verify Go is correctly installed by running the command: go version

Set up your code-space

The next step is to create a folder for where your project will live, and then set up version control (recommend git).

Next, initialise the repository as a go module with:

go mod init your_project_name

This is a time now to add some files to your .gitignore, here is what I recommend:

# vendor/

If you are on Mac you might also want to include:


Next, we will create our folders, we want the following (from the perspective of the project root):

/static css images webfonts

Finally, lets pull down the libraries we need, in this case we are using:

  1. Gin
  2. markdown

So, run these commands:

go get -u
go get -u go get -u

Markdown structure

Lets talk about how we want to use some in-file tagging so that we can set various attributes for our blog posts / pages.

Our markdown files will follow this format:

Title: Page title
Slug: page-slug
Parent: What is this a subpage of
Order: 1
Description: A subtitle / strap-line for the page which appears below the main title.
MetaDescription: SEO description of page.
MetaPropertyTitle: SEO page title for Social Media links
MetaPropertyDescription: SEO page description for Social Media links
MetaOgURL: URL of the page

Below the — we will have our page content, written in markdown. We will be able to use a mixture of HTML and markdown, so for those cases where markdown doesn’t fully cut it, don’t worry I got you covered.

By convention, I will save the markdown file in the /markdown folder as the same name as the slug, so for example this would be called:

HTML templating

Next, using some beautiful HTMX we are going to create the following pages (go and make these now):


Here is a nice write-up on templates in Go if you are unfamiliar. it would be worth a read. But the TL;DR I can come up with for you is templating allows you to glue .html pages together, and include variable data which is generated by the Go webserver on the page, contained in blocks of:

{{ // your code }}

If you are familiar with PHP, this is very much like the below tags:

<?php // your code ?>

Just before we create our html pages, in main.go we want to define some structs to hold data which we will be referencing in a html template.

So in main.go, do the following:

package main

type BlogPost struct {
	Title                   string
	Slug                    string
	Parent                  string
	Content                 template.HTML
	Description             string
	Order                   int
	Headers                 []string // for page h2's
	MetaDescription         string
	MetaPropertyTitle       string
	MetaPropertyDescription string
	MetaOgURL               string

type SidebarData struct {
	Categories []Category

type Category struct {
	Name  string
	Pages []BlogPost
	Order int

func main() {


Now, lets start off with index.html, this will be routed specifically for the home page:

{{ template "header.html" . }}
    <div class="container">
          {{ template "sidebar.html" dict "Categories" .SidebarData.Categories "CurrentSlug" .CurrentSlug }}
        <main class="main-content">
            <h1>{{ .Title }}</h1>
            <p class="description">{{ .Description }}</p>
            <hr />
            {{ .Content }}

            {{ template "footer.html" }}

        {{ template "sidebar-right.html" . }}



As you can see, we start off by calling a header, through templating (like I poorly explained, we can ‘glue’ html components together).

Then we create our container which holds a call to sidebar.html. This contains two functionalities, the first as we have explained tells the template engine to include another file, dict “Categories” .SidebarData.Categories “CurrentSlug” .CurrentSlug is creating a map and passing named parameters (Categories and CurrentSlug) to sidebar.html.

The rest of index.html should be self explanatory.

In header.html we have:

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
    <meta name="description" content="{{ .MetaDescription }}">
    <meta property="og:title" content="{{ .MetaPropertyTitle }}">
    <meta property="og:description" content="{{ .MetaPropertyDescription }}">
    <meta property="og:url" content="{{ .MetaOgURL }}">
    <title>{{ .Title }}</title>
    <link rel="stylesheet" href="/static/css/style.css">
    <link rel="stylesheet" href="">

Here, you can hopefully now see the convention of writing data to the HTML output from the server, with the {{ }}.

Hopefully you have also noticed that the data we are referring to between {{ }} so far are all included in the structs we defined in main.go.

Remember that the data that is being sent to our template from the server originates in the markdown file where we started with those tags at the top of the file. Go back to the markdown section if you need to refresh on that.

I will show you further down how we translate that from markdown to living in a Go struct, as we need to do a little server side tinkering.

sidebar.html is a fun one, here is what you want in there:

<aside class="sidebar left-sidebar">
    <a href="/"><img src="/static/images/yourlogo.jpg" /></a>

    {{ range .Categories }}
  <h2>{{ .Name }}</h2>
    {{ range .Pages }}
      <li class="{{ if eq .Slug $.CurrentSlug }}active{{ end }}">
        <a href="/{{ .Slug }}">{{ .Title }}</a>
    {{ end }}
{{ end }}


Here, I have given you a rough template for having your logo in the top left of the sidebar, followed by some Go templating code.

{{ range .Categories }} - starts a loop over Categories, which is expected to be an array or slice in the template’s context (provided by the our Go backend).

{{ range .Pages }} - within each category, another loop iterates over Pages, which are the blog posts or articles within that category. Again, think back to the markdown section where we define Category.

There are 2 structs we are referencing with this code:

  1. Referencing data from the Categories struct
  2. Referencing data from the Pages struct

The code also adds a nice little css :active tag to the link if you are currently on it, so that you can make that link a different colour to show the user which page they are on.

The line {{ if eq .Slug $.CurrentSlug }}active{{ end }} is a conditional statement in Go’s templating syntax. It checks if the current page’s slug (.Slug) matches the CurrentSlug from the template context. If they match, the active class is added to the li element.

. refers to the current context, and $.CurrentSlug accesses the CurrentSlug field from the top-level context, even when inside a nested range loop.

Next up, sidebar-right.html will hold page in-page links; these are dumb and will not highlight as you scroll through. However, it’s a nice start. This is what we want in there:

<aside class="right-sidebar">
    <nav class="toc">
            <li><a href="#">Top</a></li>
            {{ .SidebarLinks }}
    <br />
                <a href="" target="_blank">GitHub</a>

By now you should have an understanding what {{ .SidebarLinks }} is doing.

And that’s it for our basic home page template!

Building out our Go web app with Gin

Okay, now lets get to the fun bits.

We have already created our struts in main.go, so the next step is to think about how we are going to read the tags from the markdown files, as that is central to how the site works.

Lets create the following function:

func parseMarkdownFile(content []byte) (BlogPost, error) {
	sections := strings.SplitN(string(content), "---", 2)
	if len(sections) < 2 {
		return BlogPost{}, errors.New("invalid markdown format")

	metadata := sections[0]
	mdContent := sections[1]

	// deal with rogue \r's
	metadata = strings.ReplaceAll(metadata, "\r", "")
	mdContent = strings.ReplaceAll(mdContent, "\r", "")

	title, slug, parent, description, order, metaDescriptionStr,
		metaPropertyTitleStr, metaPropertyDescriptionStr,
		metaOgURLStr := parseMetadata(metadata)

	htmlContent := mdToHTML([]byte(mdContent))
	headers := extractHeaders([]byte(mdContent))

	return BlogPost{
		Title:                   title,
		Slug:                    slug,
		Parent:                  parent,
		Description:             description,
		Content:                 template.HTML(htmlContent),
		Headers:                 headers,
		Order:                   order,
		MetaDescription:         metaDescriptionStr,
		MetaPropertyTitle:       metaPropertyTitleStr,
		MetaPropertyDescription: metaPropertyDescriptionStr,
		MetaOgURL:               metaOgURLStr,
	}, nil

In the function definition, we are accepting content (AKA the .md file), a byte slice, and returning a BlogPost (struct) and an error.

We split the .md file into two sections, based on finding the sequence “—”, which is the separator between our tags and content. We check to ensure this format exists, if it doesn’t we return an error.

From this, we can define metadata and mdContent based on the two sections. We then make several calls to functions which we will define shortly, before returning the BlogPost, which is populated with data, for use in our templates.

I have also found when switching between UNIX and Windows, windows will add \r\n for the newlines, so there are 2 lines removing the \r from the markdown.

Hopefully now you can see how I have linked it all together.

func parseMetadata()

func parseMetadata(metadata string) (
	title string,
	slug string,
	parent string,
	description string,
	order int,
	metaDescription string,
	metaPropertyTitle string,
	metaPropertyDescription string,
	metaOgURL string,
) {
	re := regexp.MustCompile(`(?m)^(\w+):\s*(.+)`)
	matches := re.FindAllStringSubmatch(metadata, -1)

	metaDataMap := make(map[string]string)
	for _, match := range matches {
		if len(match) == 3 {
			metaDataMap[match[1]] = match[2]

	title = metaDataMap["Title"]
	slug = metaDataMap["Slug"]
	parent = metaDataMap["Parent"]
	description = metaDataMap["Description"]
	orderStr := metaDataMap["Order"]
	metaDescriptionStr := metaDataMap["MetaDescription"]
	metaPropertyTitleStr := metaDataMap["MetaPropertyTitle"]
	metaPropertyDescriptionStr := metaDataMap["MetaPropertyDescription"]
	metaOgURLStr := metaDataMap["MetaOgURL"]

	order, err := strconv.Atoi(orderStr)
	if err != nil {
		order = 9999 // set this to a high number in case of err

	return title, slug, parent, description, order, metaDescriptionStr,
		metaPropertyTitleStr, metaPropertyDescriptionStr, metaOgURLStr

Here we are parsing the metadata fields, and returning the value of those fields, so that we can add them to our BlogPost in the calling function.

To explain what is going on in the for loop, each match is expected to be a slice with three elements because there are two capturing groups in the regular expression (the key and the value), and the full match counts as the first element.

If the length of match is 3, the first capture (key) and the second capture (value) are used to populate the metaDataMap map. The key is the field name like Title or Slug, and the value is the corresponding value for that field.

The values from metaDataMap are then used to set the variables that will be returned by the function.

func mdToHTML

func mdToHTML(md []byte) []byte {
	extensions := parser.CommonExtensions | parser.AutoHeadingIDs
	parser := parser.NewWithExtensions(extensions)

	opts := html.RendererOptions{
		Flags: html.CommonFlags | html.HrefTargetBlank,
	renderer := html.NewRenderer(opts)

	doc := parser.Parse(md)

	output := markdown.Render(doc, renderer)

	return output

Here we are simply calling functions from the markdown library we imported, so that it can convert markdown to html.

Finally for this part, func extractHeaders for returning the in-page header links:

func extractHeaders(content []byte) []string {
	var headers []string
	//match only level 2 markdown headers
	re := regexp.MustCompile(`(?m)^##\s+(.*)`)
	matches := re.FindAllSubmatch(content, -1)

	for _, match := range matches {
		// match[1] contains header text without the '##'
		headers = append(headers, string(match[1]))

	return headers

There are a few other functions included in main.go, but rather than go through them all in meticulous detail, visit the repo and fork it or take a look around. Otherwise, we would be here all day!

You should now have a good understanding on how we are building a whole blogging platform, simply from Go and markdown. Which leads me to the final technical section…


Here is a very basic idea of how the routing is configured, this includes an extra html template called layout.html, which is basically a carbon copy of index.html.

Probably best not to include Log.Fatal in prod.. Just fyi leaving it in as at the time of writing this I'm still debugging it, so please go and change that. :)

We are also not dealing with the left sidebar here, as this post will become even longer than it is now!

func main() {

	r := gin.Default()

	// load templates

	// serve static assets
	r.Static("/static", "./static")

	// load and parse markdown files
	posts, err := loadMarkdownPosts("./markdown")
	if err != nil {

	// create a single route for the home page
	r.GET("/", func(c *gin.Context) {
		indexPath := "./markdown/"
		indexContent, err := os.ReadFile(indexPath)
		if err != nil {

		post, err := parseMarkdownFile(indexContent)
		if err != nil {

		sidebarLinks := createSidebarLinks(post.Headers)

		c.HTML(http.StatusOK, "index.html", gin.H{
			"Title":                   post.Title,
			"Content":                 post.Content,
			"SidebarData":             sidebarData,
			"Headers":                 post.Headers,
			"SidebarLinks":            sidebarLinks,
			"CurrentSlug":             post.Slug,
			"MetaDescription":         post.MetaDescription,
			"MetaPropertyTitle":       post.MetaPropertyTitle,
			"MetaPropertyDescription": post.MetaPropertyDescription,
			"MetaOgURL":               post.MetaOgURL,

	// routes for each blog post, based of of Slug following the /
	for _, post := range posts {
		localPost := post
		if localPost.Slug != "" {
			sidebarLinks := createSidebarLinks(localPost.Headers)
			r.GET("/"+localPost.Slug, func(c *gin.Context) {
				c.HTML(http.StatusOK, "layout.html", gin.H{
					"Title":                   localPost.Title,
					"Content":                 localPost.Content,
					"SidebarData":             sidebarData,
					"Headers":                 localPost.Headers,
					"Description":             localPost.Description,
					"SidebarLinks":            sidebarLinks,
					"CurrentSlug":             localPost.Slug,
					"MetaDescription":         localPost.MetaDescription,
					"MetaPropertyTitle":       localPost.MetaPropertyTitle,
					"MetaPropertyDescription": localPost.MetaPropertyDescription,
					"MetaOgURL":               localPost.MetaOgURL,
		} else {
			log.Printf("Warning: Post titled '%s' has an empty slug and will not be accessible via a unique URL.\n", localPost.Title)

	r.NoRoute(func(c *gin.Context) {
		c.HTML(http.StatusNotFound, "404.html", gin.H{
			"Title": "Page Not Found",


And, that’s it! Hopefully you can see how easy it is to create a blogging platform based on markdown, I have published this as a wireframe repo on GitHub that you can fork and play about with, and turn into something much stronger!